diff --git a/.gitignore b/.gitignore index 3a7632168..a3c362567 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,15 @@ doc .yardoc kittens.rb +mu.yaml.rb Mu_CA.pem install/headlesscheatsheet bin/consul bin/vault .vscode Berksfile.lock -cloud-mu-*.gem \ No newline at end of file +cloud-mu-*.gem +coverage +spec/mu.yaml +spec/azure_creds +mu-gen-docs-index* diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml old mode 100755 new mode 100644 index 48816a8c9..1e9d10c15 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -17,6 +17,9 @@ Rubocop: - rubocop modules/ - rubocop bin/ allow_failure: true + except: + variables: + - $IMAGE_BUILD Cookstyle: stage: Lint Test @@ -24,18 +27,27 @@ Cookstyle: script: - cookstyle cookbooks/ allow_failure: true + except: + variables: + - $IMAGE_BUILD Foodcritic: stage: Lint Test image: chef/chefdk:latest script: - foodcritic cookbooks/ -t ~FC075 -t ~FC015 -t ~FC034 -t ~FC122 -X firewall/* + except: + variables: + - $IMAGE_BUILD Foodcritic Deprecations: stage: Lint Test image: chef/chefdk:latest script: - foodcritic cookbooks/ -t deprecated -t chef13 -t chef14 -t chef15 -X cokbooks/firewall/* + except: + variables: + - $IMAGE_BUILD ChefSpec: stage: Test @@ -43,16 +55,36 @@ ChefSpec: script: - for d in ./cookbooks/*/ ; do (cd "$d" && chef exec rspec); done allow_failure: true + except: + variables: + - $IMAGE_BUILD .Rspec: stage: Test + before_script: + - cat spec/azure_creds | sed -e "s/__AZURE_CLIENT_ID__/${AZURE_CLIENT_ID}/" > spec/azure_creds.tmp + - cp spec/azure_creds.tmp spec/azure_creds + - cat spec/azure_creds | sed -e "s/__AZURE_CLIENT_SECRET__/${AZURE_CLIENT_SECRET}/" > spec/azure_creds.tmp + - cp spec/azure_creds.tmp spec/azure_creds + - cat spec/mu.yaml | sed -e "s/__AZURE_DIRECTORY_ID__/${AZURE_CLIENT_SECRET}/" > spec/mu.yaml.tmp + - cp spec/mu.yaml.tmp spec/mu.yaml + - cat spec/mu.yaml | sed -e "s/__AZURE_CLIENT_SECRET__/${AZURE_CLIENT_SECRET}/" > spec/mu.yaml.tmp + - cp spec/mu.yaml.tmp spec/mu.yaml script: - cd modules - bundle install - - gem install rspec + - gem install rspec simplecov simplecov-console - cd ../ - rspec + after_script: + - shred -u spec/azure_creds + - shred -u spec/azure_creds.tmp + - shred -u spec/mu.yaml + - shred -u spec/mu.yaml.tmp allow_failure: true + except: + variables: + - $IMAGE_BUILD New_Berks: stage: Test @@ -63,6 +95,9 @@ New_Berks: - for d in `ls -1 ./cookbooks | grep -v '^firewall$'` ; do (echo && echo "Installing $d" && cd "cookbooks/$d" && berks install); done - for d in `ls -1 ./cookbooks | grep -v '^firewall$'` ; do (echo && echo "Verifying $d" && cd "cookbooks/$d" && berks verify); done - for d in `ls -1 ./cookbooks | grep -v '^firewall$'` ; do (echo && echo "Analyzing $d" && cd "cookbooks/$d" && berks outdated); done + except: + variables: + - $IMAGE_BUILD Berks: stage: Test @@ -74,6 +109,9 @@ Berks: - berks install - berks verify - berks outdated + except: + variables: + - $IMAGE_BUILD Gem Build: stage: Build @@ -88,6 +126,9 @@ Gem Build: artifacts: paths: - cloud-mu-*.gem + except: + variables: + - $IMAGE_BUILD .Muby Build: stage: Build @@ -102,12 +143,18 @@ Gem Build: - master - development allow_failure: true + except: + variables: + - $IMAGE_BUILD Gem Parser Test: stage: Smoke Test script: - curl https://gist.githubusercontent.com/ryantiger658/87ee6aca72802ce55211a7e6c6bfa76f/raw/aaa54c255936dcb7495b6befeababd44c162922d/.mu.yaml >> /root/.mu.yaml - gem install cloud-mu-*.gem + - MY_ADDR=`hostname -I | awk '{print $1}'` + - mu-configure -n --aws-access-key="${AWS_ACCESS_KEY_ID}" --aws-access-secret="${AWS_SECRET_ACCESS_KEY}" --azure-directory-id="${AZURE_DIRECTORY_ID}" --azure-client-id="${AZURE_CLIENT_ID}" --azure-client-secret="${AZURE_CLIENT_SECRET}" --azure-subscription="${AZURE_SUBSCIPTION_ID}" --google-credentials-encoded="${GOOGLE_CREDS_ENCODED}" --mu-admin-email="egt-labs-dev@eglobaltech.com" --public-address="${MY_ADDR}" --google-project="egt-labs-admin" --google-region=us-east4 --aws-region=us-east-1 --azure-region=eastus + - cat /root/.mu.yaml - mu-deploy -d modules/tests/super_simple_bok.yml - mu-deploy -d modules/tests/super_complex_bok.yml dependencies: @@ -118,10 +165,15 @@ Gem Parser Test: - /^gem-.*$/ - /^cicd-.*$/ allow_failure: true + except: + variables: + - $IMAGE_BUILD Smoke Test: stage: Smoke Test script: + - MY_ADDR=`hostname -I | awk '{print $1}'` + - mu-configure -n --aws-access-key="${AWS_ACCESS_KEY_ID}" --aws-access-secret="${AWS_SECRET_ACCESS_KEY}" --azure-directory-id="${AZURE_DIRECTORY_ID}" --azure-client-id="${AZURE_CLIENT_ID}" --azure-client-secret="${AZURE_CLIENT_SECRET}" --azure-subscription="${AZURE_SUBSCIPTION_ID}" --google-credentials-encoded="${GOOGLE_CREDS_ENCODED}" --mu-admin-email="egt-labs-dev@eglobaltech.com" --public-address="${MY_ADDR}" --google-project="egt-labs-admin" --google-region=us-east4 --aws-region=us-east-1 --azure-region=eastus - mu-upload-chef-artifacts -sn - mu-deploy /opt/mu/var/demo_platform/applications/gitlab-server.yml -p vpc_id=vpc-040da43493f894a8d tags: @@ -130,6 +182,9 @@ Smoke Test: - master - development when: manual + except: + variables: + - $IMAGE_BUILD .Dockerbuild: stage: Dockerbuild @@ -139,12 +194,18 @@ Smoke Test: only: - master - development + except: + variables: + - $IMAGE_BUILD Gen Docs: stage: Merge/Tag script: - curl https://gist.githubusercontent.com/ryantiger658/87ee6aca72802ce55211a7e6c6bfa76f/raw/aaa54c255936dcb7495b6befeababd44c162922d/.mu.yaml >> /root/.mu.yaml + - MY_ADDR=`hostname -I | awk '{print $1}'` - gem install cloud-mu-*.gem + - ruby bin/mu-configure -n --aws-access-key="${AWS_ACCESS_KEY_ID}" --aws-access-secret="${AWS_SECRET_ACCESS_KEY}" --azure-directory-id="${AZURE_DIRECTORY_ID}" --azure-client-id="${AZURE_CLIENT_ID}" --azure-client-secret="${AZURE_CLIENT_SECRET}" --azure-subscription="${AZURE_SUBSCIPTION_ID}" --google-credentials-encoded="${GOOGLE_CREDS_ENCODED}" --mu-admin-email="egt-labs-dev@eglobaltech.com" --public-address="${MY_ADDR}" --google-project="egt-labs-admin" --google-region=us-east4 --aws-region=us-east-1 --azure-region=eastus + - cat /root/.mu.yaml - ruby bin/mu-gen-docs - mkdir public - cp -Rf /var/www/html/docs/* public @@ -158,6 +219,9 @@ Gen Docs: artifacts: paths: - public/ + except: + variables: + - $IMAGE_BUILD .GitHub Pages: stage: Deploy @@ -178,6 +242,9 @@ Gen Docs: - master - gen_docs allow_failure: true + except: + variables: + - $IMAGE_BUILD Upload Gem: stage: Deploy @@ -192,6 +259,9 @@ Upload Gem: - Gem Build only: - master + except: + variables: + - $IMAGE_BUILD Upload Gem Manual: stage: Deploy @@ -208,6 +278,9 @@ Upload Gem Manual: - development - /^gem-.*$/ - /^cicd-.*$/ + except: + variables: + - $IMAGE_BUILD pages: stage: Deploy @@ -221,3 +294,26 @@ pages: only: - master - gen_docs + except: + variables: + - $IMAGE_BUILD + +Base Images Linux: + stage: Deploy + script: + - /opt/mu/lib/extras/generate-stock-images --clouds AWS --aws-creds egtprod --platforms centos6 centos7 rhel7 + tags: + - mu-gitlab-runner + only: + variables: + - $IMAGE_BUILD == "do_linux" + +Base Images Windows: + stage: Deploy + script: + - /opt/mu/lib/extras/generate-stock-images --clouds AWS --aws-creds egtprod --platforms win2k12 win2k16 + tags: + - mu-gitlab-runner + only: + variables: + - $IMAGE_BUILD == "do_windows" diff --git a/.rubocop.yml b/.rubocop.yml index 2afb91a3c..d832d3d8e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -2,5 +2,41 @@ Style: Enabled: false Layout: Enabled: false + +# Yeah, we do these. Wanna fight? Metrics/LineLength: Enabled: false +Lint/StringConversionInInterpolation: + Enabled: false +Lint/Loop: + Enabled: false +Lint/NonLocalExitFromIterator: + Enabled: false +Lint/NestedMethodDefinition: + Enabled: false +Lint/LiteralAsCondition: + Enabled: false +Lint/EnsureReturn: + Enabled: false +Lint/EmptyEnsure: + Enabled: false + +# Complaining about these will get you mocked +Naming/MethodName: + Enabled: false +Naming/VariableName: + Enabled: false + +# These genuinely matter +Lint/ShadowingOuterLocalVariable: + Severity: error +Lint/AssignmentInCondition: + Severity: error +Lint/ShadowedArgument: + Severity: error + +# This is a generated file that only exists for YARD +AllCops: + Exclude: + - modules/mu/kittens.rb + - modules/mu/mu.yaml.rb diff --git a/Berksfile b/Berksfile index c5a6e9f64..e116a85a6 100644 --- a/Berksfile +++ b/Berksfile @@ -1,5 +1,5 @@ -source "https://supermarket.chef.io" source chef_repo: "cookbooks/" +source "https://supermarket.chef.io" # Mu Platform Cookbooks cookbook 'awscli' @@ -7,12 +7,11 @@ cookbook 'mu-activedirectory' cookbook 'mu-splunk' cookbook 'mu-firewall' cookbook 'mu-glusterfs' -cookbook 'mu-jenkins' cookbook 'mu-master' cookbook 'mu-mongo' cookbook 'mu-openvpn' -cookbook 'mu-php54' cookbook 'mu-tools' cookbook 'mu-utility' -cookbook 'nagios' -cookbook 'firewall' +cookbook 'mu-nagios' , '~> 8.2.0', git: "https://github.com/cloudamatic/mu-nagios.git" +cookbook 'firewall', path: 'cookbooks/firewall' +cookbook 'chocolatey' diff --git a/README.md b/README.md index 14efbcd26..887bcf58b 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,26 @@ mu -- Cloudamatic Automation Tooling [![Inline docs](http://inch-ci.org/github/cloudamatic/mu.svg?branch=master)](http://inch-ci.org/github/cloudamatic/mu) # About mu -**mu** is the deployer and developer toolset for the Cloudamatic complete cloud deployment solution, designed to provision, orchestrate and manage complex platforms and applications. mu provides Cloudamatic deployers and developers with the tools and commands to automate any arbitrarily complex application, platform or combination on a wide range of infrastructure targets, starting with AWS Cloud and including other clouds, virtualized environments and bare iron. +**Mu** is the deployer and developer toolset for the Cloudamatic suite of services, designed to provision, orchestrate and manage complex platforms and applications. At [eGT Labs](https://www.eglobaltech.com/egt-labs/), we use mu for rapid prototyping of cloud migration efforts for federal customers, for managing cloud applications throughout their lifecycles, and as a tools library for cloud maintenance tasks. -For general information on Cloudamatic, see the [cloudamatic repository](https://github.com/cloudamatic/cloudamatic) +**Install instructions and tutorials**: https://github.com/cloudamatic/mu/wiki -For more detailed information on Cloudamatic architecture and mu tooling usage, see our [yard docs](https://cloudamatic.gitlab.io/mu/). +**API and configuration language documentation**: https://cloudamatic.gitlab.io/mu/ -The mu tooling is currently supported on RHEL or CentOS 6/7. +# Quick Start -## Installation -See the [README](../master/install) in the install folder for mu master installation instructions +1. `gem install cloud-mu` - Install the toolkit in your Ruby 2.4+ ecosystem. See our [install wiki](https://github.com/cloudamatic/mu/wiki/Install) for other installation options -## Usage -See the [Usage](https://github.com/cloudamatic/mu/wiki/Usage) section of our Wiki for an overview of how to use the mu tooling for deployment +2. `mu-configure` - Set up credentials to your cloud provider of choice. See the [mu-configure manual](https://github.com/cloudamatic/mu/wiki/Configuration) for more. + +3. `mu-deploy` - Build something! This will make a complete public/private VPC: + +``` +cat < myvpc.yaml +--- +appname: myvpc +- vpcs: + name: thisvpc +EOF +mu-deploy myvpc.yaml +``` diff --git a/ansible/roles/geerlingguy.firewall/.gitignore b/ansible/roles/geerlingguy.firewall/.gitignore new file mode 100644 index 000000000..f56f5b578 --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/.gitignore @@ -0,0 +1,3 @@ +*.retry +*/__pycache__ +*.pyc diff --git a/ansible/roles/geerlingguy.firewall/.travis.yml b/ansible/roles/geerlingguy.firewall/.travis.yml new file mode 100644 index 000000000..ae164d97b --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/.travis.yml @@ -0,0 +1,31 @@ +--- +language: python +services: docker + +env: + global: + - ROLE_NAME: firewall + matrix: + - MOLECULE_DISTRO: centos7 + MOLECULE_DOCKER_COMMAND: /usr/lib/systemd/systemd + - MOLECULE_DISTRO: centos6 + - MOLECULE_DISTRO: ubuntu1604 + - MOLECULE_DISTRO: ubuntu1404 + - MOLECULE_DISTRO: debian8 + +install: + # Install test dependencies. + - pip install molecule docker + +before_script: + # Use actual Ansible Galaxy role name for the project directory. + - cd ../ + - mv ansible-role-$ROLE_NAME geerlingguy.$ROLE_NAME + - cd geerlingguy.$ROLE_NAME + +script: + # Run tests. + - molecule test + +notifications: + webhooks: https://galaxy.ansible.com/api/v1/notifications/ diff --git a/ansible/roles/geerlingguy.firewall/LICENSE b/ansible/roles/geerlingguy.firewall/LICENSE new file mode 100644 index 000000000..4275cf3c1 --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) 2017 Jeff Geerling + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/ansible/roles/geerlingguy.firewall/README.md b/ansible/roles/geerlingguy.firewall/README.md new file mode 100644 index 000000000..541daf1c2 --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/README.md @@ -0,0 +1,93 @@ +# Ansible Role: Firewall (iptables) + +[![Build Status](https://travis-ci.org/geerlingguy/ansible-role-firewall.svg?branch=master)](https://travis-ci.org/geerlingguy/ansible-role-firewall) + +Installs an iptables-based firewall for Linux. Supports both IPv4 (`iptables`) and IPv6 (`ip6tables`). + +This firewall aims for simplicity over complexity, and only opens a few specific ports for incoming traffic (configurable through Ansible variables). If you have a rudimentary knowledge of `iptables` and/or firewalls in general, this role should be a good starting point for a secure system firewall. + +After the role is run, a `firewall` init service will be available on the server. You can use `service firewall [start|stop|restart|status]` to control the firewall. + +## Requirements + +None. + +## Role Variables + +Available variables are listed below, along with default values (see `defaults/main.yml`): + + firewall_state: started + firewall_enabled_at_boot: true + +Controls the state of the firewall service; whether it should be running (`firewall_state`) and/or enabled on system boot (`firewall_enabled_at_boot`). + + firewall_allowed_tcp_ports: + - "22" + - "80" + ... + firewall_allowed_udp_ports: [] + +A list of TCP or UDP ports (respectively) to open to incoming traffic. + + firewall_forwarded_tcp_ports: + - { src: "22", dest: "2222" } + - { src: "80", dest: "8080" } + firewall_forwarded_udp_ports: [] + +Forward `src` port to `dest` port, either TCP or UDP (respectively). + + firewall_additional_rules: [] + firewall_ip6_additional_rules: [] + +Any additional (custom) rules to be added to the firewall (in the same format you would add them via command line, e.g. `iptables [rule]`/`ip6tables [rule]`). A few examples of how this could be used: + + # Allow only the IP 167.89.89.18 to access port 4949 (Munin). + firewall_additional_rules: + - "iptables -A INPUT -p tcp --dport 4949 -s 167.89.89.18 -j ACCEPT" + + # Allow only the IP 214.192.48.21 to access port 3306 (MySQL). + firewall_additional_rules: + - "iptables -A INPUT -p tcp --dport 3306 -s 214.192.48.21 -j ACCEPT" + +See [Iptables Essentials: Common Firewall Rules and Commands](https://www.digitalocean.com/community/tutorials/iptables-essentials-common-firewall-rules-and-commands) for more examples. + + firewall_log_dropped_packets: true + +Whether to log dropped packets to syslog (messages will be prefixed with "Dropped by firewall: "). + + firewall_disable_firewalld: false + firewall_disable_ufw: false + +Set to `true` to disable firewalld (installed by default on RHEL/CentOS) or ufw (installed by default on Ubuntu), respectively. + +## Dependencies + +None. + +## Example Playbook + + - hosts: server + vars_files: + - vars/main.yml + roles: + - { role: geerlingguy.firewall } + +*Inside `vars/main.yml`*: + + firewall_allowed_tcp_ports: + - "22" + - "25" + - "80" + +## TODO + + - Make outgoing ports more configurable. + - Make other firewall features (like logging) configurable. + +## License + +MIT / BSD + +## Author Information + +This role was created in 2014 by [Jeff Geerling](https://www.jeffgeerling.com/), author of [Ansible for DevOps](https://www.ansiblefordevops.com/). diff --git a/ansible/roles/geerlingguy.firewall/defaults/main.yml b/ansible/roles/geerlingguy.firewall/defaults/main.yml new file mode 100644 index 000000000..3d3cceba4 --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/defaults/main.yml @@ -0,0 +1,19 @@ +--- +firewall_state: started +firewall_enabled_at_boot: true + +firewall_allowed_tcp_ports: + - "22" + - "25" + - "80" + - "443" +firewall_allowed_udp_ports: [] +firewall_forwarded_tcp_ports: [] +firewall_forwarded_udp_ports: [] +firewall_additional_rules: [] +firewall_ip6_additional_rules: [] +firewall_log_dropped_packets: true + +# Set to true to ensure other firewall management software is disabled. +firewall_disable_firewalld: false +firewall_disable_ufw: false diff --git a/ansible/roles/geerlingguy.firewall/handlers/main.yml b/ansible/roles/geerlingguy.firewall/handlers/main.yml new file mode 100644 index 000000000..378095524 --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/handlers/main.yml @@ -0,0 +1,3 @@ +--- +- name: restart firewall + service: name=firewall state=restarted diff --git a/ansible/roles/geerlingguy.firewall/meta/.galaxy_install_info b/ansible/roles/geerlingguy.firewall/meta/.galaxy_install_info new file mode 100644 index 000000000..bb530379f --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/meta/.galaxy_install_info @@ -0,0 +1,2 @@ +install_date: Tue Mar 19 16:46:01 2019 +version: 2.4.1 diff --git a/ansible/roles/geerlingguy.firewall/meta/main.yml b/ansible/roles/geerlingguy.firewall/meta/main.yml new file mode 100644 index 000000000..2587263d9 --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/meta/main.yml @@ -0,0 +1,26 @@ +--- +dependencies: [] + +galaxy_info: + author: geerlingguy + description: Simple iptables firewall for most Unix-like systems. + company: "Midwestern Mac, LLC" + license: "license (BSD, MIT)" + min_ansible_version: 2.4 + platforms: + - name: EL + versions: + - all + - name: Debian + versions: + - all + - name: Ubuntu + versions: + - all + galaxy_tags: + - networking + - system + - security + - firewall + - iptables + - tcp diff --git a/ansible/roles/geerlingguy.firewall/molecule/default/molecule.yml b/ansible/roles/geerlingguy.firewall/molecule/default/molecule.yml new file mode 100644 index 000000000..bf499d2b3 --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/molecule/default/molecule.yml @@ -0,0 +1,40 @@ +--- +dependency: + name: galaxy +driver: + name: docker +lint: + name: yamllint + options: + config-file: molecule/default/yaml-lint.yml +platforms: + - name: instance + image: geerlingguy/docker-${MOLECULE_DISTRO:-centos7}-ansible + command: ${MOLECULE_DOCKER_COMMAND:-"sleep infinity"} + privileged: true + pre_build_image: true +provisioner: + name: ansible + lint: + name: ansible-lint + playbooks: + converge: ${MOLECULE_PLAYBOOK:-playbook.yml} +scenario: + name: default + test_sequence: + - lint + - destroy + - dependency + - syntax + - create + - prepare + - converge + - idempotence + - check + - side_effect + - verify + - destroy +verifier: + name: testinfra + lint: + name: flake8 diff --git a/ansible/roles/geerlingguy.firewall/molecule/default/playbook.yml b/ansible/roles/geerlingguy.firewall/molecule/default/playbook.yml new file mode 100644 index 000000000..a7cecd132 --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/molecule/default/playbook.yml @@ -0,0 +1,17 @@ +--- +- name: Converge + hosts: all + become: true + + vars: + firewall_allowed_tcp_ports: + - "9123" + + pre_tasks: + - name: Update apt cache. + apt: update_cache=true cache_valid_time=1200 + when: ansible_os_family == 'Debian' + changed_when: false + + roles: + - role: geerlingguy.firewall diff --git a/ansible/roles/geerlingguy.firewall/molecule/default/tests/test_default.py b/ansible/roles/geerlingguy.firewall/molecule/default/tests/test_default.py new file mode 100644 index 000000000..eedd64a1d --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/molecule/default/tests/test_default.py @@ -0,0 +1,14 @@ +import os + +import testinfra.utils.ansible_runner + +testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner( + os.environ['MOLECULE_INVENTORY_FILE']).get_hosts('all') + + +def test_hosts_file(host): + f = host.file('/etc/hosts') + + assert f.exists + assert f.user == 'root' + assert f.group == 'root' diff --git a/ansible/roles/geerlingguy.firewall/molecule/default/yaml-lint.yml b/ansible/roles/geerlingguy.firewall/molecule/default/yaml-lint.yml new file mode 100644 index 000000000..a3dbc38ee --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/molecule/default/yaml-lint.yml @@ -0,0 +1,6 @@ +--- +extends: default +rules: + line-length: + max: 120 + level: warning diff --git a/ansible/roles/geerlingguy.firewall/tasks/disable-other-firewalls.yml b/ansible/roles/geerlingguy.firewall/tasks/disable-other-firewalls.yml new file mode 100644 index 000000000..509196a30 --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/tasks/disable-other-firewalls.yml @@ -0,0 +1,66 @@ +--- +- name: Check if firewalld package is installed (on RHEL). + command: yum list installed firewalld + args: + warn: false + register: firewalld_installed + ignore_errors: true + changed_when: false + when: + - ansible_os_family == "RedHat" + - firewall_disable_firewalld + check_mode: false + +- name: Disable the firewalld service (on RHEL, if configured). + service: + name: firewalld + state: stopped + enabled: false + when: + - ansible_os_family == "RedHat" + - firewall_disable_firewalld + - firewalld_installed.rc == 0 + +- name: Check if ufw package is installed (on Ubuntu). + command: service ufw status + args: + warn: false + register: ufw_installed + ignore_errors: true + changed_when: false + when: + - ansible_distribution == "Ubuntu" + - firewall_disable_ufw + check_mode: false + +- name: Disable the ufw firewall (on Ubuntu, if configured). + service: + name: ufw + state: stopped + enabled: false + when: + - ansible_distribution == "Ubuntu" + - firewall_disable_ufw + - ufw_installed.rc == 0 + +- name: Check if ufw package is installed (on Archlinux). + command: pacman -Q ufw + args: + warn: false + register: ufw_installed + ignore_errors: true + changed_when: false + when: + - ansible_distribution == "Archlinux" + - firewall_disable_ufw + check_mode: false + +- name: Disable the ufw firewall (on Archlinux, if configured). + service: + name: ufw + state: stopped + enabled: false + when: + - ansible_distribution == "Archlinux" + - firewall_disable_ufw + - ufw_installed.rc == 0 diff --git a/ansible/roles/geerlingguy.firewall/tasks/main.yml b/ansible/roles/geerlingguy.firewall/tasks/main.yml new file mode 100644 index 000000000..df1a631d1 --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/tasks/main.yml @@ -0,0 +1,44 @@ +--- +- name: Ensure iptables is present. + package: name=iptables state=present + +- name: Flush iptables the first time playbook runs. + command: > + iptables -F + creates=/etc/firewall.bash + +- name: Copy firewall script into place. + template: + src: firewall.bash.j2 + dest: /etc/firewall.bash + owner: root + group: root + mode: 0744 + notify: restart firewall + +- name: Copy firewall init script into place. + template: + src: firewall.init.j2 + dest: /etc/init.d/firewall + owner: root + group: root + mode: 0755 + when: "ansible_service_mgr != 'systemd'" + +- name: Copy firewall systemd unit file into place (for systemd systems). + template: + src: firewall.unit.j2 + dest: /etc/systemd/system/firewall.service + owner: root + group: root + mode: 0644 + when: "ansible_service_mgr == 'systemd'" + +- name: Configure the firewall service. + service: + name: firewall + state: "{{ firewall_state }}" + enabled: "{{ firewall_enabled_at_boot }}" + +- import_tasks: disable-other-firewalls.yml + when: firewall_disable_firewalld or firewall_disable_ufw diff --git a/ansible/roles/geerlingguy.firewall/templates/firewall.bash.j2 b/ansible/roles/geerlingguy.firewall/templates/firewall.bash.j2 new file mode 100644 index 000000000..f355e6846 --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/templates/firewall.bash.j2 @@ -0,0 +1,136 @@ +#!/bin/bash +# iptables firewall for common LAMP servers. +# +# This file should be located at /etc/firewall.bash, and is meant to work with +# Jeff Geerling's firewall init script. +# +# Common port reference: +# 22: SSH +# 25: SMTP +# 80: HTTP +# 123: NTP +# 443: HTTPS +# 2222: SSH alternate +# 4949: Munin +# 6082: Varnish admin +# 8080: HTTP alternate (often used with Tomcat) +# 8983: Tomcat HTTP +# 8443: Tomcat HTTPS +# 9000: SonarQube +# +# @author Jeff Geerling + +# No spoofing. +if [ -e /proc/sys/net/ipv4/conf/all/rp_filter ] +then +for filter in /proc/sys/net/ipv4/conf/*/rp_filter +do +echo 1 > $filter +done +fi + +# Completely reset the firewall by removing all rules and chains. +iptables -P INPUT ACCEPT +iptables -P FORWARD ACCEPT +iptables -P OUTPUT ACCEPT +iptables -t nat -F +iptables -t mangle -F +iptables -F +iptables -X + +# Accept traffic from loopback interface (localhost). +iptables -A INPUT -i lo -j ACCEPT + +# Forwarded ports. +{# Add a rule for each forwarded port #} +{% for forwarded_port in firewall_forwarded_tcp_ports %} +iptables -t nat -I PREROUTING -p tcp --dport {{ forwarded_port.src }} -j REDIRECT --to-port {{ forwarded_port.dest }} +iptables -t nat -I OUTPUT -p tcp -o lo --dport {{ forwarded_port.src }} -j REDIRECT --to-port {{ forwarded_port.dest }} +{% endfor %} +{% for forwarded_port in firewall_forwarded_udp_ports %} +iptables -t nat -I PREROUTING -p udp --dport {{ forwarded_port.src }} -j REDIRECT --to-port {{ forwarded_port.dest }} +iptables -t nat -I OUTPUT -p udp -o lo --dport {{ forwarded_port.src }} -j REDIRECT --to-port {{ forwarded_port.dest }} +{% endfor %} + +# Open ports. +{# Add a rule for each open port #} +{% for port in firewall_allowed_tcp_ports %} +iptables -A INPUT -p tcp -m tcp --dport {{ port }} -j ACCEPT +{% endfor %} +{% for port in firewall_allowed_udp_ports %} +iptables -A INPUT -p udp -m udp --dport {{ port }} -j ACCEPT +{% endfor %} + +# Accept icmp ping requests. +iptables -A INPUT -p icmp -j ACCEPT + +# Allow NTP traffic for time synchronization. +iptables -A OUTPUT -p udp --dport 123 -j ACCEPT +iptables -A INPUT -p udp --sport 123 -j ACCEPT + +# Additional custom rules. +{% for rule in firewall_additional_rules %} +{{ rule }} +{% endfor %} + +# Allow established connections: +iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + +# Log EVERYTHING (ONLY for Debug). +# iptables -A INPUT -j LOG + +{% if firewall_log_dropped_packets %} +# Log other incoming requests (all of which are dropped) at 15/minute max. +iptables -A INPUT -m limit --limit 15/minute -j LOG --log-level 7 --log-prefix "Dropped by firewall: " +{% endif %} + +# Drop all other traffic. +iptables -A INPUT -j DROP + + +# Configure IPv6 if ip6tables is present. +if [ -x "$(which ip6tables 2>/dev/null)" ]; then + + # Remove all rules and chains. + ip6tables -F + ip6tables -X + + # Accept traffic from loopback interface (localhost). + ip6tables -A INPUT -i lo -j ACCEPT + + # Open ports. + {# Add a rule for each open port #} + {% for port in firewall_allowed_tcp_ports %} + ip6tables -A INPUT -p tcp -m tcp --dport {{ port }} -j ACCEPT + {% endfor %} + {% for port in firewall_allowed_udp_ports %} + ip6tables -A INPUT -p udp -m udp --dport {{ port }} -j ACCEPT + {% endfor %} + + # Accept icmp ping requests. + ip6tables -A INPUT -p icmp -j ACCEPT + + # Allow NTP traffic for time synchronization. + ip6tables -A OUTPUT -p udp --dport 123 -j ACCEPT + ip6tables -A INPUT -p udp --sport 123 -j ACCEPT + + # Additional custom rules. + {% for rule in firewall_ip6_additional_rules %} + {{ rule }} + {% endfor %} + + # Allow established connections: + ip6tables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + + # Log EVERYTHING (ONLY for Debug). + # ip6tables -A INPUT -j LOG + + {% if firewall_log_dropped_packets %} + # Log other incoming requests (all of which are dropped) at 15/minute max. + ip6tables -A INPUT -m limit --limit 15/minute -j LOG --log-level 7 --log-prefix "Dropped by firewall: " + {% endif %} + + # Drop all other traffic. + ip6tables -A INPUT -j DROP + +fi diff --git a/ansible/roles/geerlingguy.firewall/templates/firewall.init.j2 b/ansible/roles/geerlingguy.firewall/templates/firewall.init.j2 new file mode 100644 index 000000000..1235e94c8 --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/templates/firewall.init.j2 @@ -0,0 +1,52 @@ +#! /bin/sh +# /etc/init.d/firewall +# +# Firewall init script, to be used with /etc/firewall.bash by Jeff Geerling. +# +# @author Jeff Geerling + +### BEGIN INIT INFO +# Provides: firewall +# Required-Start: $remote_fs $syslog +# Required-Stop: $remote_fs $syslog +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: Start firewall at boot time. +# Description: Enable the firewall. +### END INIT INFO + +# Carry out specific functions when asked to by the system +case "$1" in + start) + echo "Starting firewall." + /etc/firewall.bash + ;; + stop) + echo "Stopping firewall." + iptables -F + if [ -x "$(which ip6tables 2>/dev/null)" ]; then + ip6tables -F + fi + ;; + restart) + echo "Restarting firewall." + /etc/firewall.bash + ;; + status) + echo -e "`iptables -L -n`" + EXIT=4 # program or service status is unknown + NUMBER_OF_RULES=$(iptables-save | grep '^\-' | wc -l) + if [ 0 -eq $NUMBER_OF_RULES ]; then + EXIT=3 # program is not running + else + EXIT=0 # program is running or service is OK + fi + exit $EXIT + ;; + *) + echo "Usage: /etc/init.d/firewall {start|stop|status|restart}" + exit 1 + ;; +esac + +exit 0 diff --git a/ansible/roles/geerlingguy.firewall/templates/firewall.unit.j2 b/ansible/roles/geerlingguy.firewall/templates/firewall.unit.j2 new file mode 100644 index 000000000..5165d88ff --- /dev/null +++ b/ansible/roles/geerlingguy.firewall/templates/firewall.unit.j2 @@ -0,0 +1,12 @@ +[Unit] +Description=Firewall +After=syslog.target network.target + +[Service] +Type=oneshot +ExecStart=/etc/firewall.bash +ExecStop=/sbin/iptables -F +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/mu-installer/README.md b/ansible/roles/mu-installer/README.md new file mode 100644 index 000000000..69f69322a --- /dev/null +++ b/ansible/roles/mu-installer/README.md @@ -0,0 +1,33 @@ +Role Name +========= + +Runs a basic install of Mu on a compatible host. See also https://github.com/cloudamatic/mu + +Requirements +------------ + +CentOS 6, CentOS 7, or Amazon Linux 2 host with internet connectivity and no other major services running. + +License +------- + +Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved + +Licensed under the BSD-3 license (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License in the root of the project or at + + http://egt-labs.com/mu/LICENSE.html + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Author Information +------------------ + +Current developers: John Stange, Robert Patt-Corner, Ryan Bolyard, Zach Rowe + +egt-labs-admins@egt-labs.com diff --git a/ansible/roles/mu-installer/defaults/main.yml b/ansible/roles/mu-installer/defaults/main.yml new file mode 100644 index 000000000..42bc7776b --- /dev/null +++ b/ansible/roles/mu-installer/defaults/main.yml @@ -0,0 +1,2 @@ +--- +# defaults file for mu-installer \ No newline at end of file diff --git a/ansible/roles/mu-installer/handlers/main.yml b/ansible/roles/mu-installer/handlers/main.yml new file mode 100644 index 000000000..bfd967c5d --- /dev/null +++ b/ansible/roles/mu-installer/handlers/main.yml @@ -0,0 +1,2 @@ +--- +# handlers file for mu-installer \ No newline at end of file diff --git a/ansible/roles/mu-installer/meta/main.yml b/ansible/roles/mu-installer/meta/main.yml new file mode 100644 index 000000000..5d50bf41b --- /dev/null +++ b/ansible/roles/mu-installer/meta/main.yml @@ -0,0 +1,60 @@ +galaxy_info: + author: your name + description: your description + company: your company (optional) + + # If the issue tracker for your role is not on github, uncomment the + # next line and provide a value + # issue_tracker_url: http://example.com/issue/tracker + + # Some suggested licenses: + # - BSD (default) + # - MIT + # - GPLv2 + # - GPLv3 + # - Apache + # - CC-BY + license: license (GPLv2, CC-BY, etc) + + min_ansible_version: 2.4 + + # If this a Container Enabled role, provide the minimum Ansible Container version. + # min_ansible_container_version: + + # Optionally specify the branch Galaxy will use when accessing the GitHub + # repo for this role. During role install, if no tags are available, + # Galaxy will use this branch. During import Galaxy will access files on + # this branch. If Travis integration is configured, only notifications for this + # branch will be accepted. Otherwise, in all cases, the repo's default branch + # (usually master) will be used. + #github_branch: + + # + # Provide a list of supported platforms, and for each platform a list of versions. + # If you don't wish to enumerate all versions for a particular platform, use 'all'. + # To view available platforms and versions (or releases), visit: + # https://galaxy.ansible.com/api/v1/platforms/ + # + # platforms: + # - name: Fedora + # versions: + # - all + # - 25 + # - name: SomePlatform + # versions: + # - all + # - 1.0 + # - 7 + # - 99.99 + + galaxy_tags: [] + # List tags for your role here, one per line. A tag is a keyword that describes + # and categorizes the role. Users find roles by searching for tags. Be sure to + # remove the '[]' above, if you add tags to this list. + # + # NOTE: A tag is limited to a single word comprised of alphanumeric characters. + # Maximum 20 tags per role. + +dependencies: [] + # List your role dependencies here, one per line. Be sure to remove the '[]' above, + # if you add dependencies to this list. \ No newline at end of file diff --git a/ansible/roles/mu-installer/tasks/main.yml b/ansible/roles/mu-installer/tasks/main.yml new file mode 100644 index 000000000..e67748fb8 --- /dev/null +++ b/ansible/roles/mu-installer/tasks/main.yml @@ -0,0 +1,13 @@ +--- +- name: Copy installer script + copy: + dest: /root/mu_install.sh + src: ../../../../install/installer + mode: 0700 + become: yes + +- name: Run Mu Installer + command: /root/mu_install.sh -n --mu-admin-email {{ mu_admin_email }} --mu-admin-name {{ mu_service_name }} --hostname {{ mu_service_name }} --public-address {{ mu_canonical_ip }} + environment: + MU_BRANCH: development + become: yes diff --git a/ansible/roles/mu-installer/tests/inventory b/ansible/roles/mu-installer/tests/inventory new file mode 100644 index 000000000..878877b07 --- /dev/null +++ b/ansible/roles/mu-installer/tests/inventory @@ -0,0 +1,2 @@ +localhost + diff --git a/ansible/roles/mu-installer/tests/test.yml b/ansible/roles/mu-installer/tests/test.yml new file mode 100644 index 000000000..9823d931c --- /dev/null +++ b/ansible/roles/mu-installer/tests/test.yml @@ -0,0 +1,5 @@ +--- +- hosts: localhost + remote_user: root + roles: + - mu-installer \ No newline at end of file diff --git a/ansible/roles/mu-installer/vars/main.yml b/ansible/roles/mu-installer/vars/main.yml new file mode 100644 index 000000000..ef9f012b0 --- /dev/null +++ b/ansible/roles/mu-installer/vars/main.yml @@ -0,0 +1,2 @@ +--- +# vars file for mu-installer \ No newline at end of file diff --git a/ansible/roles/mu-nat/README.md b/ansible/roles/mu-nat/README.md new file mode 100644 index 000000000..76ea0feeb --- /dev/null +++ b/ansible/roles/mu-nat/README.md @@ -0,0 +1,33 @@ +Role Name +========= + +Configure a basic iptables-based NAT + +Requirements +------------ + +CentOS 7, RHEL 7, or Amazon Linux 2 + +License +------- + +Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved + +Licensed under the BSD-3 license (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License in the root of the project or at + + http://egt-labs.com/mu/LICENSE.html + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Author Information +------------------ + +Current developers: John Stange, Robert Patt-Corner, Ryan Bolyard, Zach Rowe + +egt-labs-admins@egt-labs.com diff --git a/ansible/roles/mu-nat/defaults/main.yml b/ansible/roles/mu-nat/defaults/main.yml new file mode 100644 index 000000000..541b3edff --- /dev/null +++ b/ansible/roles/mu-nat/defaults/main.yml @@ -0,0 +1,3 @@ +--- +mu: + nat_ip_block: 10.0.0.0/16 diff --git a/ansible/roles/mu-nat/handlers/main.yml b/ansible/roles/mu-nat/handlers/main.yml new file mode 100644 index 000000000..bfd967c5d --- /dev/null +++ b/ansible/roles/mu-nat/handlers/main.yml @@ -0,0 +1,2 @@ +--- +# handlers file for mu-installer \ No newline at end of file diff --git a/ansible/roles/mu-nat/meta/main.yml b/ansible/roles/mu-nat/meta/main.yml new file mode 100644 index 000000000..5d50bf41b --- /dev/null +++ b/ansible/roles/mu-nat/meta/main.yml @@ -0,0 +1,60 @@ +galaxy_info: + author: your name + description: your description + company: your company (optional) + + # If the issue tracker for your role is not on github, uncomment the + # next line and provide a value + # issue_tracker_url: http://example.com/issue/tracker + + # Some suggested licenses: + # - BSD (default) + # - MIT + # - GPLv2 + # - GPLv3 + # - Apache + # - CC-BY + license: license (GPLv2, CC-BY, etc) + + min_ansible_version: 2.4 + + # If this a Container Enabled role, provide the minimum Ansible Container version. + # min_ansible_container_version: + + # Optionally specify the branch Galaxy will use when accessing the GitHub + # repo for this role. During role install, if no tags are available, + # Galaxy will use this branch. During import Galaxy will access files on + # this branch. If Travis integration is configured, only notifications for this + # branch will be accepted. Otherwise, in all cases, the repo's default branch + # (usually master) will be used. + #github_branch: + + # + # Provide a list of supported platforms, and for each platform a list of versions. + # If you don't wish to enumerate all versions for a particular platform, use 'all'. + # To view available platforms and versions (or releases), visit: + # https://galaxy.ansible.com/api/v1/platforms/ + # + # platforms: + # - name: Fedora + # versions: + # - all + # - 25 + # - name: SomePlatform + # versions: + # - all + # - 1.0 + # - 7 + # - 99.99 + + galaxy_tags: [] + # List tags for your role here, one per line. A tag is a keyword that describes + # and categorizes the role. Users find roles by searching for tags. Be sure to + # remove the '[]' above, if you add tags to this list. + # + # NOTE: A tag is limited to a single word comprised of alphanumeric characters. + # Maximum 20 tags per role. + +dependencies: [] + # List your role dependencies here, one per line. Be sure to remove the '[]' above, + # if you add dependencies to this list. \ No newline at end of file diff --git a/ansible/roles/mu-nat/tasks/main.yml b/ansible/roles/mu-nat/tasks/main.yml new file mode 100644 index 000000000..32cc118e6 --- /dev/null +++ b/ansible/roles/mu-nat/tasks/main.yml @@ -0,0 +1,65 @@ +--- +- name: remove firewalld + package: + name: firewalld + state: absent + +- name: make sure iptables is available + package: + name: iptables-services + state: present + +- name: Enable ip_forward + sysctl: + name: net.ipv4.ip_forward + value: '1' + state: present + +- name: Disable send_redirects + sysctl: + name: net.ipv4.conf.eth0.send_redirects + value: '0' + state: present + +- name: NAT postrouting + iptables: + table: nat + chain: POSTROUTING + out_interface: eth0 + source: "{{ mu['nat_ip_block'] }}" + jump: MASQUERADE + +- name: NAT stateful connections + iptables: + chain: INPUT + ctstate: ESTABLISHED,RELATED + jump: ACCEPT + +- name: allow inbound from NAT network + iptables: + chain: INPUT + source: "{{ mu['nat_ip_block'] }}" + jump: ACCEPT + +- name: flushy + iptables: + chain: FORWARD + flush: yes + +- name: allow forward of NAT network (outbound) + iptables: + chain: FORWARD + source: "{{ mu['nat_ip_block'] }}" + jump: ACCEPT + +- name: allow forward of NAT network (inbound) + iptables: + chain: FORWARD + destination: "{{ mu['nat_ip_block'] }}" + ctstate: ESTABLISHED,RELATED + jump: ACCEPT + +- name: Default forwarding policy to ACCEPT + iptables: + chain: FORWARD + policy: DROP diff --git a/ansible/roles/mu-nat/tests/inventory b/ansible/roles/mu-nat/tests/inventory new file mode 100644 index 000000000..878877b07 --- /dev/null +++ b/ansible/roles/mu-nat/tests/inventory @@ -0,0 +1,2 @@ +localhost + diff --git a/ansible/roles/mu-nat/tests/test.yml b/ansible/roles/mu-nat/tests/test.yml new file mode 100644 index 000000000..9823d931c --- /dev/null +++ b/ansible/roles/mu-nat/tests/test.yml @@ -0,0 +1,5 @@ +--- +- hosts: localhost + remote_user: root + roles: + - mu-installer \ No newline at end of file diff --git a/ansible/roles/mu-nat/vars/main.yml b/ansible/roles/mu-nat/vars/main.yml new file mode 100644 index 000000000..ef9f012b0 --- /dev/null +++ b/ansible/roles/mu-nat/vars/main.yml @@ -0,0 +1,2 @@ +--- +# vars file for mu-installer \ No newline at end of file diff --git a/bin/mu-adopt b/bin/mu-adopt new file mode 100755 index 000000000..7fd941b94 --- /dev/null +++ b/bin/mu-adopt @@ -0,0 +1,125 @@ +#!/usr/local/ruby-current/bin/ruby +# +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require File.expand_path(File.dirname(__FILE__))+"/mu-load-config.rb" + +require 'rubygems' +require 'bundler/setup' +require 'optimist' +require 'mu' + +available_clouds = MU::Cloud.supportedClouds +available_clouds.reject! { |cloud| + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud) + cloudclass.listCredentials.nil? or cloudclass.listCredentials.size == 0 +} + +available_types = MU::Cloud.resource_types.keys.map { |t| t.to_s } +grouping_options = { + "logical" => "Group resources in logical layers (folders and habitats together, users/roles/groups together, network resources together, etc)", + "omnibus" => "Jam everything into one monolothic configuration" +} + +$opt = Optimist::options do + banner <<-EOS +#{$0} + EOS + opt :appname, "The overarching name of the application stack we will generate", :required => false, :default => "mu", :type => :string + opt :types, "The resource types to scan and import. Valid types: #{available_types.join(", ")}", :required => false, :type => :strings, :default => available_types + opt :clouds, "The cloud providers to scan and import.", :required => false, :type => :strings, :default => available_clouds + opt :parent, "Where applicable, resources which reside in the root folder or organization are configured with the specified parent in our target BoK", :required => false, :type => :string + opt :billing, "Force-set this billing entity on created resources, instead of copying from the live resources", :required => false, :type => :string + opt :sources, "One or more sets of credentials to use when importing resources. By default we will search and import from all sets of available credentials for each cloud provider specified with --clouds", :required => false, :type => :strings + opt :credentials, "Override the 'credentials' value in our generated Baskets of Kittens to target a single, specific account. Our default behavior is to set each resource to deploy into the account from which it was sourced.", :required => false, :type => :string + opt :savedeploys, "Generate actual deployment metadata in #{MU.dataDir}/deployments, as though the resources we found were created with mu-deploy. If we are generating more than one configuration, and a resource needs to reference another resource (e.g. to declare a VPC in which to reside), this will allow us to reference them as virtual resource, rather than by raw cloud identifier.", :required => false, :type => :boolean + opt :diff, "List the differences between what we find and an existing, saved deploy from a previous run, if one exists.", :required => false, :type => :boolean + opt :grouping, "Methods for grouping found resources into separate Baskets.\n\n"+MU::Adoption::GROUPMODES.keys.map { |g| "* "+g.to_s+": "+MU::Adoption::GROUPMODES[g] }.join("\n")+"\n\n", :required => false, :type => :string, :default => "logical" + opt :habitats, "Limit scope of research searching to the named accounts/projects/subscriptions, instead of search all habitats visible to our credentials.", :required => false, :type => :strings +end + +ok = true + +app_pattern = Regexp.new('^[a-z][0-9a-z\-_]{0,10}[a-z0-9]$', true) + +if !$opt[:appname] or !app_pattern.match($opt[:appname]) + MU.log "--appname must match pattern #{app_pattern.to_s}", MU::ERR + exit 1 +end + +if !$opt[:savedeploys_given] + $opt[:savedeploys] = !$opt[:diff] +end + +types = [] +$opt[:types].each { |t| + t_name = t.gsub(/-/, "_") + t_name.gsub!(/^[^a-z0-9]|[^a-z0-9]$/i, "") + shortclass, name, plural, classname = MU::Cloud.getResourceNames(t_name) + if !classname + MU.log "'#{t}' does not map to a valid Mu resource type", MU::ERR + ok = false + else + types << shortclass + end +} + +clouds = [] +if !$opt[:clouds] or $opt[:clouds].empty? + MU.log "At least one cloud must be specified", MU::ERR + ok = false +end +$opt[:clouds].each { |cloud| + found_match = false + MU::Cloud.supportedClouds.each { |known_cloud| + if cloud.match(/^[^a-z0-9]*?#{Regexp.quote(known_cloud)}[^a-z0-9]*?$/i) + clouds << known_cloud + found_match = true + break + end + } + if !found_match + MU.log "'#{cloud}' does not map to a valid Mu cloud layer", MU::ERR + ok = false + end +} + +if !ok + puts "Invoke with --help for more information." + exit 1 +end + + +adoption = MU::Adoption.new(clouds: clouds, types: types, parent: $opt[:parent], billing: $opt[:billing], sources: $opt[:sources], credentials: $opt[:credentials], group_by: $opt[:grouping].to_sym, savedeploys: $opt[:savedeploys], diff: $opt[:diff], habitats: $opt[:habitats]) +adoption.scrapeClouds +MU.log "Generating baskets", MU::DEBUG +boks = adoption.generateBaskets(prefix: $opt[:appname]) + +boks.each_pair { |appname, bok| + MU.log "Writing to #{appname}.yaml" + File.open("#{appname}.yaml", "w") { |f| + f.write JSON.parse(JSON.generate(bok)).to_yaml + } + conf_engine = MU::Config.new("#{appname}.yaml") + stack_conf = conf_engine.config +# puts stack_conf.to_yaml + MU.log "#{appname}.yaml validated successfully", MU::NOTICE + MU::Cloud.resource_types.each_pair { |type, cfg| + if bok[cfg[:cfg_plural]] + MU.log "#{bok[cfg[:cfg_plural]].size.to_s} #{cfg[:cfg_plural]}", MU::NOTICE + end + } +} + diff --git a/bin/mu-ansible-secret b/bin/mu-ansible-secret new file mode 100755 index 000000000..260a615eb --- /dev/null +++ b/bin/mu-ansible-secret @@ -0,0 +1,114 @@ +#!/usr/local/ruby-current/bin/ruby +# +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require File.expand_path(File.dirname(__FILE__))+"/mu-load-config.rb" + +require 'rubygems' +require 'bundler/setup' +require 'optimist' +require 'mu' + +$secretdir = MU.dataDir + "/ansible-secrets" + +$opts = Optimist::options do + banner <<-EOS +Interface with Mu's central repository of Ansible vaults. All encrypting/decrypting will take place with the current user's default Mu Ansible Vault password, which is automatically generated. +#{$0} [--create|--update [[] --file | --string ]] | [--delete|--show []] | [--list] | [--string [] ] + EOS + opt :list, "List vaults owned by this user.", :require => false, :default => false, :type => :boolean + opt :show, "Show a vault or item. If only a vault name is specified, item names are listed. Otherwise, item contents are shown.", :require => false, :default => false, :type => :boolean + opt :create, "Create a new vault and item", :require => false, :default => false, :type => :boolean + opt :update, "Alias for --create", :require => false, :default => false, :type => :boolean + opt :delete, "", :require => false, :default => false, :type => :boolean + opt :file, "Path to a file to encrypt, in lieu of encrypting string data provided as an argument", :require => false, :type => :string + opt :string, "Encrypt a string, suitable for embedding in an Ansible vars file. If the optional argument is not provided, the variable will be called my_encrypted_variable", :require => false, :type => :string +end + +def bail(err) + MU.log err, MU::ERR + Optimist::educate + exit 1 +end + +if $opts[:list] + MU::Groomer::Ansible.listSecrets.each { |vault| + puts vault + } + exit +end + +if $opts[:string] + namestr = if ARGV.size != 1 + "my_encrypted_var" + else + ARGV.shift + end + MU::Groomer::Ansible.encryptString(namestr, $opts[:string]) + exit +end + +if $opts[:show] + bail("Must specify a vault name with --show") if ARGV.size == 0 + vaultname = ARGV.shift + itemname = ARGV.shift if ARGV.size > 0 + + data = MU::Groomer::Ansible.getSecret(vault: vaultname, item: itemname) + if !data + MU.log "No data returned from vault #{vaultname} #{itemname ? "item "+itemname : ""}" + elsif data.is_a?(Array) + data.each { |entry| + puts entry + } + elsif data.is_a?(Hash) + puts JSON.pretty_generate(data) + else + puts data + end + exit +end + +if $opts[:create] or $opts[:update] + bail("Must specify a vault name with --create or --update") if ARGV.size == 0 + vaultname = ARGV.shift + data = if $opts[:file] + item = $opts[:file].gsub(/.*?([^\/]+)$/, '\1') + if ARGV.size > 0 + bail("Cannot specify item arg with --file (extra argument(s): #{ARGV.join(" ")})") + end + File.read($opts[:file]) + elsif $opts[:string] + bail("Must specify an item name when using --string") if ARGV.size == 0 + item = ARGV.shift + $opts[:string] + data = ARGV.shift + if ARGV.size > 0 + bail("Don't know what to do with extra argument(s): #{ARGV.join(" ")}") + end + data + else + bail("Must specify either --file or --string when using --create or --update") + end + MU::Groomer::Ansible.saveSecret(vault: vaultname, item: item, data: data) + exit +end + +if $opts[:delete] + bail("Must specify at least a vault name with --delete") if ARGV.size == 0 + vaultname = ARGV.shift + itemname = ARGV.shift if ARGV.size > 0 + MU::Groomer::Ansible.deleteSecret(vault: vaultname, item: itemname) + exit +end diff --git a/bin/mu-aws-setup b/bin/mu-aws-setup index e4088f118..99bfbfb2f 100755 --- a/bin/mu-aws-setup +++ b/bin/mu-aws-setup @@ -86,36 +86,90 @@ end # Create a security group, or manipulate an existing one, so that we have all # of the appropriate network holes. if $opts[:sg] - open_ports = [80, 443, 2260, 7443, 8443, 9443, 8200] + open_ports = [443, MU.mommaCatPort, 7443, 8443, 9443, 8200] + ranges = if $MU_CFG and $MU_CFG['my_networks'] and $MU_CFG['my_networks'].size > 0 + $MU_CFG['my_networks'].map { |r| + r = r+"/32" if r.match(/^\d+\.\d+\.\d+\.\d+$/) + r + } + else + ["0.0.0.0/0"] + end # This doesn't make sense. we can have multiple security groups in our account with a name tag of "Mu Master". This will then find and modify a security group that has nothing to do with us. - # found = MU::MommaCat.findStray("AWS", "firewall_rule", region: MU.myRegion, dummy_ok: true, tag_key: "Name", tag_value: "Mu Master") - found = nil - if found.nil? or found.size < 1 and instance.security_groups.size > 0 - # maybe we should make sure we don't use the "Mu Client Rules for" security group for this. - found = MU::MommaCat.findStray("AWS", "firewall_rule", region: MU.myRegion, dummy_ok: true, cloud_id: instance.security_groups.first.group_id) + admin_sg = nil + if instance.security_groups.size > 0 + instance.security_groups.each { |sg| + found = MU::MommaCat.findStray("AWS", "firewall_rule", region: MU.myRegion, dummy_ok: true, cloud_id: sg.group_id) + if found.size > 0 and + !found.first.cloud_desc.group_name.match(/^Mu Client Rules for /) + admin_sg = found.first + + break + end + } end - admin_sg = found.first if !found.nil? and found.size > 0 + + # Clean out any old rules that aren't part of our current config + admin_sg.cloud_desc.ip_permissions.each { |rule| + rule.ip_ranges.each { |range| + if range.description == "Mu Master service access" and + !ranges.include?(range.cidr_ip) and rule.to_port != 80 and + !(rule.to_port == 22 and range.cidr_ip == "#{preferred_ip}/32") + MU.log "Revoking old Mu Master service access rule for #{range.cidr_ip} port #{rule.to_port.to_s}", MU::NOTICE + MU::Cloud::AWS.ec2(region: MU.myRegion, credentials: admin_sg.credentials).revoke_security_group_ingress( + group_id: admin_sg.cloud_desc.group_id, + ip_permissions: [ + { + to_port: rule.to_port, + from_port: rule.from_port, + ip_protocol: rule.ip_protocol, + ip_ranges: [ + { cidr_ip: range.cidr_ip } + ] + } + ] + ) + + end + } + } + + rules = Array.new + open_ports.each { |port| + rules << { + "port" => port, + "hosts" => ranges, + "description" => "Mu Master service access" + } + } + rules << { + "port" => 22, + "hosts" => ["#{preferred_ip}/32"], + "description" => "Mu Master service access" + } + rules << { + "port" => 80, + "hosts" => ["0.0.0.0/0"], + "description" => "Mu Master service access" + } + rules << { + "port_range" => "0-65535", + "sgs" => admin_sg.cloud_id, + "description" => "Mu Master service access" + } + MU.log "Configuring basic TCP access for Mu services", MU::NOTICE, details: rules if !admin_sg.nil? MU.log "Using an existing Security Group, #{admin_sg}, already associated with this Mu server." open_ports.each { |port| - admin_sg.addRule(["0.0.0.0/0"], port: port) + admin_sg.addRule(ranges, port: port, comment: "Mu Master service access") } - admin_sg.addRule(["#{preferred_ip}/32"], port: 22) + admin_sg.addRule(["#{preferred_ip}/32"], port: 22, comment: "Mu Master service access") + admin_sg.addRule(["0.0.0.0/0"], port: 80, comment: "Mu Master service access") + admin_sg.addRule([admin_sg.cloud_id], comment: "Mu Master service access") else - rules = Array.new - open_ports.each { |port| - rules << { - "port" => port, - "hosts" => ["0.0.0.0/0"] - } - } - rules << { - "port" => 22, - "hosts" => ["#{preferred_ip}/32"] - } cfg = { "name" => "Mu Master", "cloud" => "AWS", @@ -141,7 +195,6 @@ if instance.public_ip_address != preferred_ip and !preferred_ip.nil? and !prefer filters << {name: "domain", values: ["vpc"]} if !instance.vpc_id.nil? filters << {name: "public-ip", values: [instance.public_ip_address]} resp = MU::Cloud::AWS.ec2.describe_addresses(filters: filters) - pp resp if resp.addresses.size > 0 has_elastic_ip end @@ -192,7 +245,7 @@ if $opts[:logs] resp = MU::Cloud::AWS.s3(credentials: credset).list_buckets resp.buckets.each { |bucket| - exists = true if bucket['name'] == bucketname + exists = true if bucket.name == bucketname } if !exists MU.log "Creating #{bucketname} bucket" @@ -220,7 +273,7 @@ if $opts[:logs] body: "#{key}" ) end - if File.exists?("#{MU.mySSLDir}/Mu_CA.pem") + if File.exist?("#{MU.mySSLDir}/Mu_CA.pem") MU.log "Putting the Mu Master's public SSL certificate into #{bucketname}/Mu_CA.pem" MU::Cloud::AWS.s3(credentials: credset).put_object( bucket: bucketname, @@ -385,7 +438,7 @@ end if $opts[:uploadlogs] today = Time.new.strftime("%Y%m%d").to_s ["master.log", "nodes.log"].each { |log| - if File.exists?("/Mu_Logs/#{log}-#{today}") + if File.exist?("/Mu_Logs/#{log}-#{today}") MU.log "Uploading /Mu_Logs/#{log}-#{today} to bucket #{$bucketname}" MU::Cloud::AWS.s3.put_object( bucket: $bucketname, diff --git a/bin/mu-azure-setup b/bin/mu-azure-setup new file mode 100755 index 000000000..3b5f22ef9 --- /dev/null +++ b/bin/mu-azure-setup @@ -0,0 +1,265 @@ +#!/usr/local/ruby-current/bin/ruby +# +# Copyright:: Copyright (c) 2017 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Perform initial Mu setup tasks: +# 1. Set up an appropriate Security Group +# 2. Associate a specific Elastic IP address to this MU server, if required. +# 3. Create an S3 bucket for Mu logs. + +require 'etc' +require 'securerandom' + +require File.expand_path(File.dirname(__FILE__))+"/mu-load-config.rb" + +require 'rubygems' +require 'bundler/setup' +require 'json' +require 'erb' +require 'optimist' +require 'json-schema' +require 'mu' +require 'mu/master/ssl' +Dir.chdir(MU.installDir) + +$opts = Optimist::options do + banner <<-EOS +Usage: +#{$0} [-i] [-s] [-l] [-u] [-d] + EOS +# opt :ip, "Attempt to configure the IP requested in the CHEF_PUBLIC_IP environment variable, or if none is set, to associate an arbitrary Elastic IP.", :require => false, :default => false, :type => :boolean + opt :sg, "Attempt to configure a Security Group with appropriate permissions.", :require => false, :default => false, :type => :boolean + opt :logs, "Ensure the presence of an Cloud Storage bucket prefixed with 'Mu_Logs' for use with CloudTrails, syslog, etc.", :require => false, :default => false, :type => :boolean +# opt :dns, "Ensure the presence of a private DNS Zone called for internal amongst Mu resources.", :require => false, :default => false, :type => :boolean + opt :uploadlogs, "Push today's log files to the Cloud Storage bucket created by the -l option.", :require => false, :default => false, :type => :boolean +end + +if MU::Cloud::Azure.hosted? and !$MU_CFG['google'] + new_cfg = $MU_CFG.dup + cfg_blob = MU::Cloud::Azure.hosted_config + if cfg_blob + cfg_blob['log_bucket_name'] ||= $MU_CFG['hostname'] + new_cfg["google"] = { "default" => cfg_blob } + MU.log "Adding auto-detected Azure stanza to #{cfgPath}", MU::NOTICE + if new_cfg != $MU_CFG or !cfgExists? + MU.log "Generating #{cfgPath}" + saveMuConfig(new_cfg) + $MU_CFG = new_cfg + end + end +end + +sgs_to_ifaces = {} +ifaces_to_sgs = {} +sgs = [] +if MU::Cloud::Azure.hosted? + instance = MU.myCloudDescriptor + # Azure VMs can have exactly one security group per network interface, so if + # there's already one, we use it. + iface_num = 0 + instance.network_profile.network_interfaces.each { |iface| + iface_id = MU::Cloud::Azure::Id.new(iface.id) + ifaces_to_sgs[iface_id] = false + iface_desc = MU::Cloud::Azure.network.network_interfaces.get(MU.myInstanceId.resource_group, iface_id.to_s) + if iface_desc.network_security_group + sg_id = MU::Cloud::Azure::Id.new(iface_desc.network_security_group.id) + sgs << sg_id + sgs_to_ifaces[sg_id] = iface_id + ifaces_to_sgs[iface_id] = sg_id + else + ifaces_to_sgs[iface_id] = "mu-master-"+MU.myInstanceId.name + ifaces_to_sgs[iface_id] += "-"+iface_num.to_s if iface_num > 0 + end + if iface_desc.ip_configurations + iface_desc.ip_configurations.each { |ipcfg| + ipcfg.subnet.id.match(/resourceGroups\/([^\/]+)\/providers\/Microsoft.Network\/virtualNetworks\/([^\/]+)\/subnets\/(.*)/) + rg = Regexp.last_match[1] + vpc_id = Regexp.last_match[2] + subnet_id = Regexp.last_match[3] + subnet = MU::Cloud::Azure.network.subnets.get( + rg, + vpc_id, + subnet_id + ) + if subnet.network_security_group + sg_id = MU::Cloud::Azure::Id.new(subnet.network_security_group.id) + sgs << sg_id + end + } + end + iface_num += 1 + } + sgs.uniq! + +# if !instance.tags.items or !instance.tags.items.include?(admin_sg_name) +# newitems = instance.tags.items ? instance.tags.items.dup : [] +# newitems << admin_sg_name +# MU.log "Setting my instance tags", MU::NOTICE, details: newitems +# newtags = MU::Cloud::Azure.compute(:Tags).new( +# fingerprint: instance.tags.fingerprint, +# items: newitems +# ) +# MU::Cloud::Azure.compute.set_instance_tags( +# MU::Cloud::Azure.myProject, +# MU.myAZ, +# MU.myInstanceId, +# newtags +# ) +# instance = MU.myCloudDescriptor +# end + preferred_ip = MU.mu_public_ip +end + +# Create a security group, or manipulate an existing one, so that we have all +# of the appropriate network holes. +if $opts[:sg] + open_ports = [80, 443, MU.mommaCatPort, 7443, 8443, 9443, 8200] + + sgs.each { |sg_id| + admin_sg_name = sg_id.is_a?(String) ? sg_id : sg_id.name + + found = MU::MommaCat.findStray("Azure", "firewall_rule", dummy_ok: true, cloud_id: admin_sg_name, region: instance.location) + admin_sg = found.first if !found.nil? and found.size > 0 + + rules = [] + open_ports.each { |port| + rules << { + "proto" => "tcp", + "port" => port.to_s + } + } + + rules << { + "proto" => "tcp", + "port" => 22 +# "hosts" => ["#{preferred_ip}/32"] + } + cfg = { + "name" => admin_sg_name, + "scrub_mu_isms" => true, + "cloud" => "Azure", + "rules" => rules, + "region" => instance.location, + "target_tags" => [admin_sg_name], + "vpc" => { + "vpc_id" => MU::Cloud::Azure::Id.new(instance.network_profile.network_interfaces.first.id) + } + } + + if !admin_sg + admin_sg = MU::Cloud::FirewallRule.new(kitten_cfg: cfg, mu_name: admin_sg_name) + admin_sg.create + admin_sg.groom + else + rules.each { |rule| + admin_sg.addRule(rule["hosts"], proto: rule["proto"], port: rule["port"].to_i) + } + end + } +end + +$bucketname = MU::Cloud::Azure.adminBucketName + +if $opts[:logs] + MU::Cloud::Azure.listCredentials.each { |credset| + bucketname = MU::Cloud::Azure.adminBucketName(credset) + exists = false + + MU.log "Configuring log and secret Azure Cloud Storage bucket '#{bucketname}'" + + bucket = nil + begin + bucket = MU::Cloud::Azure.storage(credentials: credset).get_bucket(bucketname) + rescue ::Azure::Apis::ClientError => e + if e.message.match(/notFound:/) + MU.log "Creating #{bucketname} bucket" + bucketobj = MU::Cloud::Azure.storage(:Bucket).new( + name: bucketname, + location: "US", # XXX why is this needed? + versioning: MU::Cloud::Azure.storage(:Bucket)::Versioning.new( + enabled: true + ), + lifecycle: MU::Cloud::Azure.storage(:Bucket)::Lifecycle.new( + rule: [ MU::Cloud::Azure.storage(:Bucket)::Lifecycle::Rule.new( + action: MU::Cloud::Azure.storage(:Bucket)::Lifecycle::Rule::Action.new( + type: "SetStorageClass", + storage_class: "DURABLE_REDUCED_AVAILABILITY" + ), + condition: MU::Cloud::Azure.storage(:Bucket)::Lifecycle::Rule::Condition.new( + age: 180 + ) + )] + ) + ) + bucket = MU::Cloud::Azure.storage(credentials: credset).insert_bucket( + MU::Cloud::Azure.defaultProject(credset), + bucketobj + ) + else + pp e.backtrace + raise MU::MuError, e.inspect + end + end + + ebs_key = nil + + begin + ebs_key = MU::Cloud::Azure.storage(credentials: credset).get_object(bucketname, "log_vol_ebs_key") + rescue ::Azure::Apis::ClientError => e + if e.message.match(/notFound:/) + # XXX this may not be useful outside of AWS + MU.log "Creating new key for encrypted log volume" + key = SecureRandom.random_bytes(32) + f = Tempfile.new("logvolkey") # XXX this is insecure and stupid + f.write key + f.close + objectobj = MU::Cloud::Azure.storage(:Object).new( + bucket: bucketname, + name: "log_vol_ebs_key" + ) + ebs_key = MU::Cloud::Azure.storage(credentials: credset).insert_object( + bucketname, + objectobj, + upload_source: f.path + ) + f.unlink + else + raise MuError, e.inspect + end + end +# XXX stop doing this per-bucket, chowderhead + MU::Master.disk("/dev/xvdl", "/Mu_Logs", 50, "log_vol_ebs_key", "ram7") + } + +end + +if $opts[:dns] +end + +if $opts[:uploadlogs] + today = Time.new.strftime("%Y%m%d").to_s + ["master.log", "nodes.log"].each { |log| + if File.exist?("/Mu_Logs/#{log}-#{today}") + MU.log "Uploading /Mu_Logs/#{log}-#{today} to bucket #{$bucketname}" + MU::Cloud::AWS.s3.put_object( + bucket: $bucketname, + key: "#{log}/#{today}", + body: File.read("/Mu_Logs/#{log}-#{today}") + ) + else + MU.log "No log /Mu_Logs/#{log}-#{today} was found", MU::WARN + end + } +end diff --git a/bin/mu-cleanup b/bin/mu-cleanup index 70296a49c..81f9dd945 100755 --- a/bin/mu-cleanup +++ b/bin/mu-cleanup @@ -23,6 +23,14 @@ require 'optimist' require 'mu' Dir.chdir(MU.installDir) +credentials = [] +MU::Cloud.supportedClouds.each { |cloud| + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud) + next if cloudclass.listCredentials.nil? or cloudclass.listCredentials.size == 0 + credentials.concat(cloudclass.listCredentials) +} +credentials.uniq! + $opts = Optimist::options do banner <<-EOS Usage: @@ -35,6 +43,8 @@ Usage: opt :skipcloud, "Only purge Mu master deployment metadata, and skip all cloud resources.", :require => false, :default => false, :type => :boolean opt :web, "Generate web-friendly (HTML) output.", :require => false, :default => false, :type => :boolean opt :verbose, "Display debugging output.", :require => false, :default => false, :type => :boolean + opt :credentials, "Restrict to operating on a subset of available credential sets, instead of all that we know about.", :require => false, :default => credentials, :type => :strings + opt :regions, "Restrict to operating on a subset of available regions, instead of all that we know about.", :require => false, :type => :strings opt :quiet, "Display minimal output.", :require => false, :default => false, :type => :boolean end verbosity = MU::Logger::NORMAL @@ -49,7 +59,8 @@ end MU.setLogging(verbosity, $opts[:web]) if (!ARGV[0] or ARGV[0].empty?) and !$opts[:deploy] - MU.log("You must specify a deploy id!", MU::ERR, html: $opts[:web]) + MU.log "Must specify a deploy id. Visible deploys:", MU::WARN + puts MU::MommaCat.listDeploys.sort.join("\n") exit 1 else $opts[:deploy] = ARGV[0] @@ -57,12 +68,14 @@ end MU::Cleanup.run( - $opts[:deploy], - noop: $opts[:noop], - skipsnapshots: $opts[:skipsnapshots], - onlycloud: $opts[:onlycloud], - verbosity: verbosity, - web: $opts[:web], - skipcloud: $opts[:skipcloud], - ignoremaster: $opts[:ignoremaster] + $opts[:deploy], + noop: $opts[:noop], + skipsnapshots: $opts[:skipsnapshots], + onlycloud: $opts[:onlycloud], + verbosity: verbosity, + web: $opts[:web], + skipcloud: $opts[:skipcloud], + ignoremaster: $opts[:ignoremaster], + credsets: $opts[:credentials], + regions: $opts[:regions] ) diff --git a/bin/mu-configure b/bin/mu-configure index 51055868e..2e887a750 100755 --- a/bin/mu-configure +++ b/bin/mu-configure @@ -29,12 +29,52 @@ require 'erb' require 'tmpdir' $IN_GEM = false -if Gem.paths and Gem.paths.home and File.dirname(__FILE__).match(/^#{Gem.paths.home}/) - $IN_GEM = true +gemwhich = %x{gem which mu 2>&1}.chomp +gemwhich = nil if $?.exitstatus != 0 +mypath = File.realpath(File.expand_path(File.dirname(__FILE__))) +if !mypath.match(/^\/opt\/mu/) + if Gem.paths and Gem.paths.home and + (mypath.match(/^#{Gem.paths.home}/) or gemwhich.match(/^#{Gem.paths.home}/)) + $IN_GEM = true + elsif $?.exitstatus == 0 and gemwhich and !gemwhich.empty? + $LOAD_PATH.each { |path| + if path.match(/\/cloud-mu-[^\/]+\/modules/) or + path.match(/#{Regexp.quote(gemwhich)}/) + $IN_GEM = true + end + } + end end +$possible_addresses = [] +$impossible_addresses = ['127.0.0.1', 'localhost'] +begin + sys_name = Socket.gethostname + official, aliases = Socket.gethostbyname(sys_name) + $possible_addresses << sys_name + $possible_addresses << official + $possible_addresses.concat(aliases) +rescue SocketError + # don't let them use the default hostname if it doesn't resolve + $impossible_addresses << sys_name +end +Socket.getifaddrs.each { |iface| + if iface.addr and iface.addr.ipv4? + $possible_addresses << iface.addr.ip_address + begin + addrinfo = Socket.gethostbyaddr(iface.addr.ip_address) + $possible_addresses << addrinfo.first if !addrinfo.first.nil? + rescue SocketError + # usually no name to look up; that's ok + end + end +} +$possible_addresses.uniq! +$possible_addresses.reject! { |i| i.match(/^(0\.0\.0\.0$|169\.254\.|127\.0\.)/)} + GIT_PATTERN = /(((git|ssh|http(s)?)|(git@[\w\.]+))(:(\/\/)?))?([\w\.@\:\/\-~]+)(\.git)?(\/)?/ + # Top-level keys in $MU_CFG for which we'll provide interactive, menu-driven # configuration. $CONFIGURABLES = { @@ -42,8 +82,7 @@ $CONFIGURABLES = { "title" => "Public Address", "desc" => "IP address or hostname", "required" => true, - "rootonly" => true, - "pattern" => /^(localhost|127\.0\.0\.1|#{Socket.gethostname})$/, + "pattern" => /^(#{$impossible_addresses.map { |a| Regexp.quote(a) }.join("|") })$/, "negate_pattern" => true, "changes" => ["389ds", "chef-server", "chefrun", "chefcerts"] }, @@ -52,14 +91,12 @@ $CONFIGURABLES = { "desc" => "Administative contact email", "pattern" => /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i, "required" => true, - "rootonly" => true, "changes" => ["mu-user", "chefrun"] }, "mu_admin_name" => { "title" => "Admin Name", "desc" => "Administative contact's full name", "default" => "Mu Administrator", - "rootonly" => true, "changes" => ["mu-user", "chefrun"] }, "hostname" => { @@ -70,10 +107,17 @@ $CONFIGURABLES = { "desc" => "The local system's value for HOSTNAME", "changes" => ["chefrun", "hostname"] }, + "mommacat_port" => { + "title" => "Momma Cat Listen Port", + "pattern" => /^[0-9]+$/i, + "default" => 2260, + "required" => $IN_GEM, + "desc" => "Listen port for the Momma Cat grooming daemon", + "changes" => ["chefrun"] + }, "banner" => { "title" => "Banner", "desc" => "Login banner, displayed in various locations", - "rootonly" => true, "changes" => ["chefrun"] }, "mu_repository" => { @@ -105,6 +149,11 @@ $CONFIGURABLES = { "desc" => "If set to true, Mu will be allowed to modify routing and peering behavior of VPCs which it did not create, but for which it has permissions.", "boolean" => true }, + "ansible_dir" => { + "title" => "Ansible directory", + "desc" => "Intended for use with minimal installs which use Ansible as a groomer and which do not store Ansible artifacts in a dedicated git repository. This allows simply pointing to a local directory.", + "required" => false + }, "aws" => { "title" => "Amazon Web Services", "named_subentries" => true, @@ -166,6 +215,10 @@ $CONFIGURABLES = { "title" => "Credentials File", "desc" => "JSON-formatted Service Account credentials for our GCP account, stored in plain text in a file. Generate a service account at: https://console.cloud.google.com/iam-admin/serviceaccounts/project, making sure the account has sufficient privileges to manage cloud resources. Download the private key as JSON and point this argument to the file. This is less secure than using 'credentials' to store in a vault." }, + "credentials_encoded" => { + "title" => "Base64-Encoded Credentials", + "desc" => "JSON-formatted Service Account credentials for our GCP account, b64-encoded and dropped directly into mu.yaml. Generate a service account at: https://console.cloud.google.com/iam-admin/serviceaccounts/project, making sure the account has sufficient privileges to manage cloud resources. Download the private key as JSON and point this argument to the file. This is less secure than using 'credentials' to store in a vault." + }, "region" => { "title" => "Default Region", "desc" => "Default Google Cloud Platform region in which we operate and deploy", @@ -182,6 +235,11 @@ $CONFIGURABLES = { "required" => false, "desc" => "For Google Cloud projects which are attached to a GSuite domain. GCP service accounts cannot view or manage GSuite resources (groups, users, etc) directly, but must instead masquerade as a GSuite user which has delegated authority to the service account. See also: https://developers.google.com/identity/protocols/OAuth2ServiceAccount#delegatingauthority" }, + "customer_id" => { + "title" => "GSuite Customer ID", + "required" => false, + "desc" => "For Google Cloud projects which are attached to a GSuite domain. Some API calls (groups, users, etc) require this identifier. From admin.google.com, choose Security, the Single Sign On, and look for the Entity ID field. The value after idpid= in the URL there should be the customer ID." + }, "default" => { "title" => "Is Default Account", "default" => false, @@ -194,28 +252,40 @@ $CONFIGURABLES = { "title" => "Microsoft Azure Cloud Computing Platform & Services", "named_subentries" => true, "subtree" => { + "directory_id" => { + "title" => "Directory ID", + "desc" => "AKA Tenant ID; the default Microsoft Azure Directory project in which we operate and deploy, from https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredAppsPreview" + }, + "client_id" => { + "title" => "Client ID", + "desc" => "App client id used to authenticate to our subscription. From https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredAppsPreview" + }, + "client_secret" => { + "title" => "Client Secret", + "desc" => "App client secret used to authenticate to our subscription. From https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredAppsPreview under the 'Certificates & secrets' tab, 'Client secrets.' This can only be retrieved upon initial secret creation." + }, "subscription" => { "title" => "Default Subscription", - "desc" => "Default Microsoft Azure Directory project in which we operate and deploy." - }, - "credentials" => { - "title" => "Credentials Vault:Item", - "desc" => "A secure Chef vault and item from which to retrieve the JSON-formatted Service Account credentials for our Azure account, in the format vault:itemname (e.g. 'secrets:google'). Generate a service account at: https://console.cloud.google.com/iam-admin/serviceaccounts/project, making sure the account has sufficient privileges to manage cloud resources. Download the private key as JSON, and import that key to the vault specified here. Import example: knife vault create secrets google -J my-google-service-account.json " + "desc" => "Default Microsoft Azure Subscription we will use to deploy, from https://portal.azure.com/#blade/Microsoft_Azure_Billing/SubscriptionsBlade" }, + # "credentials" => { + # "title" => "Credentials Vault:Item", + # "desc" => "A secure Chef vault and item from which to retrieve the JSON-formatted Service Account credentials for our Azure account, in the format vault:itemname (e.g. 'secrets:google'). Generate a service account at: https://console.cloud.google.com/iam-admin/serviceaccounts/project, making sure the account has sufficient privileges to manage cloud resources. Download the private key as JSON, and import that key to the vault specified here. Import example: knife vault create secrets google -J my-google-service-account.json " + # }, "credentials_file" => { "title" => "Credentials File", - "desc" => "JSON-formatted Service Account credentials for our Azure account, stored in plain text in a file." + "desc" => "JSON file which contains a hash of directory_id, client_id, client_secret, and subscription values. If found, these will be override values entered directly in mu-configure." }, "region" => { "title" => "Default Region", "desc" => "Default Microsoft Azure region in which we operate and deploy", "default" => "eastus" }, - "log_bucket_name" => { - "title" => "Log and Secret Bucket Name", - "desc" => "Cloud Storage bucket into which we'll synchronize deploy secrets, and if we're hosted in Azure, collected system logs", - "changes" => ["chefrun"] - }, + # "log_bucket_name" => { + # "title" => "Log and Secret Bucket Name", + # "desc" => "Cloud Storage bucket into which we'll synchronize deploy secrets, and if we're hosted in Azure, collected system logs", + # "changes" => ["chefrun"] + # }, "default" => { "title" => "Is Default Account", "default" => false, @@ -226,120 +296,6 @@ $CONFIGURABLES = { } } -AMROOT = Process.uid == 0 -HOMEDIR = Etc.getpwuid(Process.uid).dir - -$opts = Optimist::options do - banner <<-EOS - EOS - required = [] - opt :noninteractive, "Skip menu-based configuration prompts. If there is no existing configuration, the following flags are required: #{required.map{|x|"--"+x}.join(", ")}", :require => false, :default => false, :type => :boolean - $CONFIGURABLES.each_pair { |key, data| - next if !AMROOT and data['rootonly'] - if data.has_key?("subtree") - data["subtree"].each_pair { |subkey, subdata| - next if !AMROOT and subdata['rootonly'] - subdata['cli-opt'] = (key+"-"+subkey).gsub(/_/, "-") - opt (key+"-"+subkey).to_sym, subdata["desc"], :require => false, :type => (subdata["boolean"] ? :boolean : :string) - required << subdata['cli-opt'] if subdata['required'] - } - elsif data["array"] - data['cli-opt'] = key.gsub(/_/, "-") - opt key.to_sym, data["desc"], :require => false, :type => (data["boolean"] ? :booleans : :strings) - required << data['cli-opt'] if data['required'] - else - data['cli-opt'] = key.gsub(/_/, "-") - opt key.to_sym, data["desc"], :require => false, :type => (data["boolean"] ? :boolean : :string) - required << data['cli-opt'] if data['required'] - end - } - - opt :force, "Run all rebuild actions, whether or not our configuration is changed.", :require => false, :default => false, :type => :boolean if AMROOT - opt :ssh_keys, "One or more paths to SSH private keys, which we can try to use for SSH-based Git clone operations", :require => false, :type => :strings -end - -if ENV.has_key?("MU_INSTALLDIR") - MU_BASE = ENV["MU_INSTALLDIR"] -else - MU_BASE = "/opt/mu" -end - -$INITIALIZE = (!File.size?("#{MU_BASE}/etc/mu.yaml") or $opts[:force]) -$HAVE_GLOBAL_CONFIG = File.size?("#{MU_BASE}/etc/mu.yaml") -if !AMROOT and ($INITIALIZE or !$HAVE_GLOBAL_CONFIG) and !$IN_GEM - puts "Global configuration has not been initialized or is missing. Must run as root to correct." - exit 1 -end - -if !$HAVE_GLOBAL_CONFIG and $opts[:noninteractive] and (!$opts[:public_address] or !$opts[:mu_admin_email]) - puts "Specify --public-address and --mu-admin-email on new non-interactive configs" - exit 1 -end - -$IN_AWS = false -begin - Timeout.timeout(2) do - instance_id = open("http://169.254.169.254/latest/meta-data/instance-id").read - $IN_AWS = true if !instance_id.nil? and instance_id.size > 0 - end -rescue OpenURI::HTTPError, Timeout::Error, SocketError, Errno::ENETUNREACH -end -$IN_GOOGLE = false -begin - Timeout.timeout(2) do - instance_id = open( - "http://metadata.google.internal/computeMetadata/v1/instance/name", - "Metadata-Flavor" => "Google" - ).read - $IN_GOOGLE = true if !instance_id.nil? and instance_id.size > 0 - end -rescue OpenURI::HTTPError, Timeout::Error, SocketError, Errno::ENETUNREACH -end -$IN_AZURE = false -begin - Timeout.timeout(2) do - instance_id = open("http://169.254.169.254/metadata/instance/compute").read - $IN_AWS = true if !instance_id.nil? and instance_id.size > 0 - end -rescue OpenURI::HTTPError, Timeout::Error, SocketError, Errno::ENETUNREACH, Errno::EHOSTUNREACH -end - - -KNIFE_TEMPLATE = "log_level :info -log_location STDOUT -node_name '<%= chefuser %>' -client_key '<%= MU_BASE %>/var/users/<%= user %>/<%= chefuser %>.user.key' -validation_client_name 'mu-validator' -validation_key '<%= MU_BASE %>/var/orgs/<%= user %>/<%= chefuser %>.org.key' -chef_server_url 'https://<%= MU.mu_public_addr %>:7443/organizations/<%= chefuser %>' -chef_server_root 'https://<%= MU.mu_public_addr %>:7443/organizations/<%= chefuser %>' -syntax_check_cache_path '<%= HOMEDIR %>/.chef/syntax_check_cache' -cookbook_path [ '<%= HOMEDIR %>/.chef/cookbooks', '<%= HOMEDIR %>/.chef/site_cookbooks' ] -<% if $MU_CFG.has_key?('ssl') and $MU_CFG['ssl'].has_key?('chain') %> -ssl_ca_path '<%= File.dirname($MU_CFG['ssl']['chain']) %>' -ssl_ca_file '<%= File.basename($MU_CFG['ssl']['chain']) %>' -<% end %> -knife[:vault_mode] = 'client' -knife[:vault_admins] = ['<%= chefuser %>']" - -CLIENT_TEMPLATE = "chef_server_url 'https://<%= MU.mu_public_addr %>:7443/organizations/<%= user %>' -validation_client_name 'mu-validator' -log_location STDOUT -node_name 'MU-MASTER' -verify_api_cert false -ssl_verify_mode :verify_none -" - -PIVOTAL_TEMPLATE = "node_name 'pivotal' -chef_server_url 'https://<%= MU.mu_public_addr %>:7443' -chef_server_root 'https://<%= MU.mu_public_addr %>:7443' -no_proxy '<%= MU.mu_public_addr %>' -client_key '/etc/opscode/pivotal.pem' -ssl_verify_mode :verify_none -" - -$CHANGES = [] - def cloneHash(hash) new = {} hash.each_pair { |k,v| @@ -352,165 +308,329 @@ def cloneHash(hash) new end -$MENU_MAP = {} -def assignMenuEntries(tree = $CONFIGURABLES, map = $MENU_MAP) - count = 1 - tree.each_pair { |key, data| - next if !data.is_a?(Hash) - next if !AMROOT and data['rootonly'] - if data.has_key?("subtree") - letters = ("a".."z").to_a - lettercount = 0 - if data['named_subentries'] - # Generate a stub entry for adding a new item - map[count.to_s] = cloneHash(data["subtree"]) - map[count.to_s].each_pair { |k, v| v.delete("value") } # use defaults - map[count.to_s]["name"] = { - "title" => "Name", - "desc" => "A name/alias for this account.", - "required" => true - } - map[count.to_s]["#addnew"] = true - map[count.to_s]["#title"] = data['title'] - map[count.to_s]["#key"] = key - - # Now the menu entries for the existing ones - if data['subtree']['#entries'] - data['subtree']['#entries'].each_pair { |nameentry, subdata| - next if data['readonly'] - next if !subdata.is_a?(Hash) - subdata["#menu"] = count.to_s+letters[lettercount] - subdata["#title"] = nameentry - subdata["#key"] = key - subdata["#entries"] = cloneHash(data["subtree"]["#entries"]) - subdata["is_submenu"] = true - map[count.to_s+letters[lettercount]] = tree[key]["subtree"]['#entries'][nameentry] - map[count.to_s+letters[lettercount]]['#entries'] ||= cloneHash(data["subtree"]["#entries"]) - lettercount = lettercount + 1 +# Load values from our existing configuration into the $CONFIGURABLES hash +def importCurrentValues + require File.realpath(File.expand_path(File.dirname(__FILE__)+"/mu-load-config.rb")) + $CONFIGURABLES.each_key { |key| + next if !$MU_CFG.has_key?(key) + if $CONFIGURABLES[key].has_key?("subtree") + # It's a sub-tree. I'm too lazy to write a recursive thing for this, just + # cover the simple case that we actually care about for now. + if $CONFIGURABLES[key]["named_subentries"] + $CONFIGURABLES[key]['subtree']["#title"] = $CONFIGURABLES[key]['title'] + $MU_CFG[key].each_pair { |nameentry, subtree| + $CONFIGURABLES[key]['subtree']["#entries"] ||= {} + $CONFIGURABLES[key]['subtree']["#entries"][nameentry] = cloneHash($CONFIGURABLES[key]['subtree']) + $CONFIGURABLES[key]['subtree']["#entries"][nameentry].delete("#entries") + $CONFIGURABLES[key]["subtree"]["#entries"][nameentry]["name"] = { + "title" => "Name", + "desc" => "A name/alias for this account.", + "required" => true, + "value" => nameentry } - end + $CONFIGURABLES[key]["subtree"].keys.each { |subkey| + next if !subtree.has_key?(subkey) + $CONFIGURABLES[key]["subtree"]["#entries"][nameentry][subkey]["value"] = subtree[subkey] + } + } else - data["subtree"].each_pair { |subkey, subdata| - next if !AMROOT and subdata['rootonly'] - tree[key]["subtree"][subkey]["#menu"] = count.to_s+letters[lettercount] - tree[key]["subtree"][subkey]["#key"] = subkey - map[count.to_s+letters[lettercount]] = tree[key]["subtree"][subkey] - lettercount = lettercount + 1 + $CONFIGURABLES[key]["subtree"].keys.each { |subkey| + next if !$MU_CFG[key].has_key?(subkey) + $CONFIGURABLES[key]["subtree"][subkey]["value"] = $MU_CFG[key][subkey] } end + else + $CONFIGURABLES[key]["value"] = $MU_CFG[key] end - tree[key]["#menu"] = count.to_s - tree[key]["#key"] = key - map[count.to_s] ||= tree[key] - count = count + 1 } - map#.freeze end -def trySSHKeyWithGit(repo, keypath = nil) - cfgbackup = nil - deletekey = false - repo.match(/^([^@]+?)@([^:]+?):/) - ssh_user = Regexp.last_match(1) - ssh_host = Regexp.last_match(2) - if keypath.nil? - response = nil - puts "Would you like to provide a private ssh key for #{repo} and try again?" - begin - response = Readline.readline("Y/N> ".bold, false) - end while !response and !response.match(/^(y|n)$/i) - if response == "y" or response == "Y" - Dir.mkdir("#{HOMEDIR}/.ssh", 0700) if !Dir.exists?("#{HOMEDIR}/.ssh") - keynamestr = repo.gsub(/[^a-z0-9\-]/i, "-") + Process.pid.to_s - keypath = "#{HOMEDIR}/.ssh/#{keynamestr}" - puts "Paste a complete SSH private key for #{ssh_user.bold}@#{ssh_host.bold} below, then ^D" - system("cat > #{keypath}") - File.chmod(0600, keypath) - puts "Key saved to "+keypath.bold - deletekey = true +if !$NOOP + AMROOT = Process.uid == 0 + HOMEDIR = Etc.getpwuid(Process.uid).dir + + $opts = Optimist::options do + banner <<-EOS + EOS + required = [] + opt :noninteractive, "Skip menu-based configuration prompts. If there is no existing configuration, the following flags are required: #{required.map{|x|"--"+x}.join(", ")}", :require => false, :default => false, :type => :boolean + $CONFIGURABLES.each_pair { |key, data| + next if !AMROOT and data['rootonly'] + if data.has_key?("subtree") + data["subtree"].each_pair { |subkey, subdata| + next if !AMROOT and subdata['rootonly'] + subdata['cli-opt'] = (key+"-"+subkey).gsub(/_/, "-") + opt subdata['cli-opt'].to_sym, subdata["desc"], :require => false, :type => (subdata["boolean"] ? :boolean : :string) + required << subdata['cli-opt'] if subdata['required'] + } + elsif data["array"] + data['cli-opt'] = key.gsub(/_/, "-") + opt data['cli-opt'].to_sym, data["desc"], :require => false, :type => (data["boolean"] ? :booleans : :strings) + required << data['cli-opt'] if data['required'] + else + data['cli-opt'] = key.gsub(/_/, "-") + opt data['cli-opt'].to_sym, data["desc"], :require => false, :type => (data["boolean"] ? :boolean : :string) + required << data['cli-opt'] if data['required'] + end + } + + opt :force, "Run all rebuild actions, whether or not our configuration is changed.", :require => false, :default => false, :type => :boolean if AMROOT + opt :ssh_keys, "One or more paths to SSH private keys, which we can try to use for SSH-based Git clone operations", :require => false, :type => :strings + end + + if ENV.has_key?("MU_INSTALLDIR") + MU_BASE = ENV["MU_INSTALLDIR"] + else + MU_BASE = "/opt/mu" + end + + def cfgPath + home = Etc.getpwuid(Process.uid).dir + username = Etc.getpwuid(Process.uid).name + if Process.uid == 0 + if ENV.include?('MU_INSTALLDIR') + ENV['MU_INSTALLDIR']+"/etc/mu.yaml" + elsif Dir.exist?("/opt/mu") + "/opt/mu/etc/mu.yaml" + else + "#{home}/.mu.yaml" + end else - return false + "#{home}/.mu.yaml" end end - if File.exists?("#{HOMEDIR}/.ssh/config") - FileUtils.cp("#{HOMEDIR}/.ssh/config", "#{HOMEDIR}/.ssh/config.bak.#{Process.pid.to_s}") - cfgbackup = "#{HOMEDIR}/.ssh/config.bak.#{Process.pid.to_s}" + $INITIALIZE = (!File.size?(cfgPath) or $opts[:force]) + + $HAVE_GLOBAL_CONFIG = File.size?("#{MU_BASE}/etc/mu.yaml") + if !AMROOT and !$HAVE_GLOBAL_CONFIG and !$IN_GEM and Dir.exist?("/opt/mu/lib") + puts "Global configuration has not been initialized or is missing. Must run as root to correct." + exit 1 end - File.open("#{HOMEDIR}/.ssh/config", "a", 0600){ |f| - f.puts "Host "+ssh_host - f.puts " User "+ssh_user - f.puts " IdentityFile "+keypath - f.puts " StrictHostKeyChecking no" - } - puts "/usr/bin/git clone #{repo}" - output = %x{/usr/bin/git clone #{repo} 2>&1} - if $?.exitstatus == 0 - puts "Successfully cloned #{repo}".green.on_black - return true - else - puts output.red.on_black - if cfgbackup - puts "Restoring #{HOMEDIR}/.ssh/config" - File.rename(cfgbackup, "#{HOMEDIR}/.ssh/config") + if !$HAVE_GLOBAL_CONFIG and $opts[:noninteractive] and (!$opts[:"public-address"] or !$opts[:"mu-admin-email"]) + if $IN_GEM + importCurrentValues # maybe we're in local-only mode end - if deletekey - puts "Removing #{keypath}" - File.unlink(keypath) + if !$MU_CFG or !$MU_CFG['mu_admin_email'] or !$MU_CFG['mu_admin_name'] + puts "Specify --public-address and --mu-admin-email on new non-interactive configs" + exit 1 end end - return false -end -def cloneGitRepo(repo) - puts "Testing ability to check out Git repository #{repo.bold}" - fullrepo = repo - if !repo.match(/@|:\/\//) # we try ssh first - fullrepo = "git@github.com:"+repo - puts "Doesn't look like a full URL, trying SSH to #{fullrepo}" + $IN_AWS = false + begin + Timeout.timeout(2) do + instance_id = open("http://169.254.169.254/latest/meta-data/instance-id").read + $IN_AWS = true if !instance_id.nil? and instance_id.size > 0 + end + rescue OpenURI::HTTPError, Timeout::Error, SocketError, Errno::ENETUNREACH + end + $IN_GOOGLE = false + begin + Timeout.timeout(2) do + instance_id = open( + "http://metadata.google.internal/computeMetadata/v1/instance/name", + "Metadata-Flavor" => "Google" + ).read + $IN_GOOGLE = true if !instance_id.nil? and instance_id.size > 0 + end + rescue OpenURI::HTTPError, Timeout::Error, SocketError, Errno::ENETUNREACH + end + $IN_AZURE = false + begin + Timeout.timeout(2) do + instance = open("http://169.254.169.254/metadata/instance/compute?api-version=2017-08-01","Metadata"=>"true").read + $IN_AZURE = true if !instance.nil? and instance.size > 0 + end + rescue OpenURI::HTTPError, Timeout::Error, SocketError, Errno::ENETUNREACH, Errno::EHOSTUNREACH + end + + KNIFE_TEMPLATE = "log_level :info + log_location STDOUT + node_name '<%= chefuser %>' + client_key '<%= MU_BASE %>/var/users/<%= user %>/<%= chefuser %>.user.key' + validation_client_name 'mu-validator' + validation_key '<%= MU_BASE %>/var/orgs/<%= user %>/<%= chefuser %>.org.key' + chef_server_url 'https://<%= MU.mu_public_addr %>:7443/organizations/<%= chefuser %>' + chef_server_root 'https://<%= MU.mu_public_addr %>:7443/organizations/<%= chefuser %>' + syntax_check_cache_path '<%= HOMEDIR %>/.chef/syntax_check_cache' + cookbook_path [ '<%= HOMEDIR %>/.chef/cookbooks', '<%= HOMEDIR %>/.chef/site_cookbooks' ] + <% if $MU_CFG.has_key?('ssl') and $MU_CFG['ssl'].has_key?('chain') %> + ssl_ca_path '<%= File.dirname($MU_CFG['ssl']['chain']) %>' + ssl_ca_file '<%= File.basename($MU_CFG['ssl']['chain']) %>' + <% end %> + knife[:vault_mode] = 'client' + knife[:vault_admins] = ['<%= chefuser %>']" + + CLIENT_TEMPLATE = "chef_server_url 'https://<%= MU.mu_public_addr %>:7443/organizations/<%= user %>' + validation_client_name 'mu-validator' + log_location STDOUT + node_name 'MU-MASTER' + verify_api_cert false + ssl_verify_mode :verify_none + " + + PIVOTAL_TEMPLATE = "node_name 'pivotal' + chef_server_url 'https://<%= MU.mu_public_addr %>:7443' + chef_server_root 'https://<%= MU.mu_public_addr %>:7443' + no_proxy '<%= MU.mu_public_addr %>' + client_key '/etc/opscode/pivotal.pem' + ssl_verify_mode :verify_none + " + + $CHANGES = [] + + + $MENU_MAP = {} + def assignMenuEntries(tree = $CONFIGURABLES, map = $MENU_MAP) + count = 1 + tree.each_pair { |key, data| + next if !data.is_a?(Hash) + next if !AMROOT and data['rootonly'] + if data.has_key?("subtree") + letters = ("a".."z").to_a + lettercount = 0 + if data['named_subentries'] + # Generate a stub entry for adding a new item + map[count.to_s] = cloneHash(data["subtree"]) + map[count.to_s].each_pair { |k, v| v.delete("value") } # use defaults + map[count.to_s]["name"] = { + "title" => "Name", + "desc" => "A name/alias for this account.", + "required" => true + } + map[count.to_s]["#addnew"] = true + map[count.to_s]["#title"] = data['title'] + map[count.to_s]["#key"] = key + + # Now the menu entries for the existing ones + if data['subtree']['#entries'] + data['subtree']['#entries'].each_pair { |nameentry, subdata| + next if data['readonly'] + next if !subdata.is_a?(Hash) + subdata["#menu"] = count.to_s+letters[lettercount] + subdata["#title"] = nameentry + subdata["#key"] = key + subdata["#entries"] = cloneHash(data["subtree"]["#entries"]) + subdata["is_submenu"] = true + map[count.to_s+letters[lettercount]] = tree[key]["subtree"]['#entries'][nameentry] + map[count.to_s+letters[lettercount]]['#entries'] ||= cloneHash(data["subtree"]["#entries"]) + lettercount = lettercount + 1 + } + end + else + data["subtree"].each_pair { |subkey, subdata| + next if !AMROOT and subdata['rootonly'] + tree[key]["subtree"][subkey]["#menu"] = count.to_s+letters[lettercount] + tree[key]["subtree"][subkey]["#key"] = subkey + map[count.to_s+letters[lettercount]] = tree[key]["subtree"][subkey] + lettercount = lettercount + 1 + } + end + end + tree[key]["#menu"] = count.to_s + tree[key]["#key"] = key + map[count.to_s] ||= tree[key] + count = count + 1 + } + map#.freeze end - cwd = Dir.pwd - Dir.mktmpdir("mu-git-test-") { |dir| - Dir.chdir(dir) - puts "/usr/bin/git clone #{fullrepo}" - output = %x{/usr/bin/git clone #{fullrepo} 2>&1} + + def trySSHKeyWithGit(repo, keypath = nil) + cfgbackup = nil + deletekey = false + repo.match(/^([^@]+?)@([^:]+?):/) + ssh_user = Regexp.last_match(1) + ssh_host = Regexp.last_match(2) + if keypath.nil? + response = nil + puts "Would you like to provide a private ssh key for #{repo} and try again?" + begin + response = Readline.readline("Y/N> ".bold, false) + end while !response and !response.match(/^(y|n)$/i) + if response == "y" or response == "Y" + Dir.mkdir("#{HOMEDIR}/.ssh", 0700) if !Dir.exist?("#{HOMEDIR}/.ssh") + keynamestr = repo.gsub(/[^a-z0-9\-]/i, "-") + Process.pid.to_s + keypath = "#{HOMEDIR}/.ssh/#{keynamestr}" + puts "Paste a complete SSH private key for #{ssh_user.bold}@#{ssh_host.bold} below, then ^D" + system("cat > #{keypath}") + File.chmod(0600, keypath) + puts "Key saved to "+keypath.bold + deletekey = true + else + return false + end + end + + if File.exist?("#{HOMEDIR}/.ssh/config") + FileUtils.cp("#{HOMEDIR}/.ssh/config", "#{HOMEDIR}/.ssh/config.bak.#{Process.pid.to_s}") + cfgbackup = "#{HOMEDIR}/.ssh/config.bak.#{Process.pid.to_s}" + end + File.open("#{HOMEDIR}/.ssh/config", "a", 0600){ |f| + f.puts "Host "+ssh_host + f.puts " User "+ssh_user + f.puts " IdentityFile "+keypath + f.puts " StrictHostKeyChecking no" + } + + puts "/usr/bin/git clone #{repo}" + output = %x{/usr/bin/git clone #{repo} 2>&1} if $?.exitstatus == 0 - puts "Successfully cloned #{fullrepo}".green.on_black - Dir.chdir(cwd) - return fullrepo - elsif $?.exitstatus != 0 and output.match(/permission denied/i) - puts "" + puts "Successfully cloned #{repo}".green.on_black + return true + else puts output.red.on_black - if $opts[:ssh_keys_given] - $opts[:ssh_keys].each { |keypath| - if trySSHKeyWithGit(fullrepo, keypath) - Dir.chdir(cwd) - return fullrepo - end - } + if cfgbackup + puts "Restoring #{HOMEDIR}/.ssh/config" + File.rename(cfgbackup, "#{HOMEDIR}/.ssh/config") end - if !$opts[:noninteractive] - if trySSHKeyWithGit(fullrepo) - Dir.chdir(cwd) - return fullrepo - end + if deletekey + puts "Removing #{keypath}" + File.unlink(keypath) end end - if !repo.match(/@|:\/\//) - fullrepo = "git://github.com/"+repo - puts "" - puts "No luck there, trying #{fullrepo}".bold + return false + end + + def cloneGitRepo(repo) + puts "Testing ability to check out Git repository #{repo.bold}" + fullrepo = repo + if !repo.match(/@|:\/\//) # we try ssh first + fullrepo = "git@github.com:"+repo + puts "Doesn't look like a full URL, trying SSH to #{fullrepo}" + end + cwd = Dir.pwd + Dir.mktmpdir("mu-git-test-") { |dir| + Dir.chdir(dir) puts "/usr/bin/git clone #{fullrepo}" output = %x{/usr/bin/git clone #{fullrepo} 2>&1} if $?.exitstatus == 0 puts "Successfully cloned #{fullrepo}".green.on_black Dir.chdir(cwd) return fullrepo - else + elsif $?.exitstatus != 0 and output.match(/permission denied/i) + puts "" puts output.red.on_black - fullrepo = "https://github.com/"+repo - puts "Final attempt, trying #{fullrepo}" + if $opts[:"ssh-keys-given"] + $opts[:"ssh-keys"].each { |keypath| + if trySSHKeyWithGit(fullrepo, keypath) + Dir.chdir(cwd) + return fullrepo + end + } + end + if !$opts[:noninteractive] + if trySSHKeyWithGit(fullrepo) + Dir.chdir(cwd) + return fullrepo + end + end + end + if !repo.match(/@|:\/\//) + fullrepo = "git://github.com/"+repo + puts "" + puts "No luck there, trying #{fullrepo}".bold puts "/usr/bin/git clone #{fullrepo}" output = %x{/usr/bin/git clone #{fullrepo} 2>&1} if $?.exitstatus == 0 @@ -519,819 +639,827 @@ def cloneGitRepo(repo) return fullrepo else puts output.red.on_black + fullrepo = "https://github.com/"+repo + puts "Final attempt, trying #{fullrepo}" + puts "/usr/bin/git clone #{fullrepo}" + output = %x{/usr/bin/git clone #{fullrepo} 2>&1} + if $?.exitstatus == 0 + puts "Successfully cloned #{fullrepo}".green.on_black + Dir.chdir(cwd) + return fullrepo + else + puts output.red.on_black + end end + else + puts "No other methods I can think to try, giving up on #{repo.bold}".red.on_black end - else - puts "No other methods I can think to try, giving up on #{repo.bold}".red.on_black - end - } - Dir.chdir(cwd) - nil -end + } + Dir.chdir(cwd) + nil + end -# Rustle up some sensible default values, if this is our first time -def setDefaults - ips = [] - if $IN_AWS - ["public-ipv4", "local-ipv4"].each { |addr| + # Rustle up some sensible default values, if this is our first time + def setDefaults + ips = [] + if $IN_AWS + ["public-ipv4", "local-ipv4"].each { |addr| + begin + Timeout.timeout(2) do + ip = open("http://169.254.169.254/latest/meta-data/#{addr}").read + ips << ip if !ip.nil? and ip.size > 0 + end + rescue OpenURI::HTTPError, Timeout::Error, SocketError + # these are ok to ignore + end + } + elsif $IN_GOOGLE + base_url = "http://metadata.google.internal/computeMetadata/v1" begin - Timeout.timeout(2) do - ip = open("http://169.254.169.254/latest/meta-data/#{addr}").read - ips << ip if !ip.nil? and ip.size > 0 - end - rescue OpenURI::HTTPError, Timeout::Error, SocketError - # these are ok to ignore - end - } - elsif $IN_GOOGLE - base_url = "http://metadata.google.internal/computeMetadata/v1" - begin - Timeout.timeout(2) do -# TODO iterate across multiple interfaces/access-configs - ip = open("#{base_url}/instance/network-interfaces/0/ip", "Metadata-Flavor" => "Google").read - ips << ip if !ip.nil? and ip.size > 0 - ip = open("#{base_url}/instance/network-interfaces/0/access-configs/0/external-ip", "Metadata-Flavor" => "Google").read - ips << ip if !ip.nil? and ip.size > 0 + Timeout.timeout(2) do + # TODO iterate across multiple interfaces/access-configs + ip = open("#{base_url}/instance/network-interfaces/0/ip", "Metadata-Flavor" => "Google").read + ips << ip if !ip.nil? and ip.size > 0 + ip = open("#{base_url}/instance/network-interfaces/0/access-configs/0/external-ip", "Metadata-Flavor" => "Google").read + ips << ip if !ip.nil? and ip.size > 0 + end + rescue OpenURI::HTTPError, Timeout::Error, SocketError => e + # This is fairly normal, just handle it gracefully end - rescue OpenURI::HTTPError, Timeout::Error, SocketError => e - # This is fairly normal, just handle it gracefully end - end - ips.concat(Socket.ip_address_list.delete_if { |i| !i.ipv4? or i.ip_address.match(/^(0\.0\.0\.0$|169\.254\.|127\.0\.)/) }.map { |a| a.ip_address }) - $CONFIGURABLES["allow_invade_foreign_vpcs"]["default"] = false - $CONFIGURABLES["public_address"]["default"] = ips.first - $CONFIGURABLES["hostname"]["default"] = Socket.gethostname - $CONFIGURABLES["banner"]["default"] = "Mu Master at #{$CONFIGURABLES["public_address"]["default"]}" - if $IN_AWS -# XXX move this crap to a callback hook for puttering around in the AWS submenu - aws = JSON.parse(open("http://169.254.169.254/latest/dynamic/instance-identity/document").read) - iam = nil - begin - iam = open("http://169.254.169.254/latest/meta-data/iam/security-credentials").read - rescue OpenURI::HTTPError, SocketError - end -# $CONFIGURABLES["aws"]["subtree"]["account_number"]["default"] = aws["accountId"] - $CONFIGURABLES["aws"]["subtree"]["region"]["default"] = aws["region"] - if iam and iam.size > 0 - # XXX can we think of a good way to test our permission set? - $CONFIGURABLES["aws"]["subtree"]["access_key"]["desc"] = $CONFIGURABLES["aws"]["subtree"]["access_key"]["desc"] + ". Not necessary if IAM Profile #{iam.bold} has sufficient API access." - $CONFIGURABLES["aws"]["subtree"]["access_secret"]["desc"] = $CONFIGURABLES["aws"]["subtree"]["access_key"]["desc"] + ". Not necessary if IAM Profile #{iam.bold} has sufficient API access." + $CONFIGURABLES["allow_invade_foreign_vpcs"]["default"] = false + $CONFIGURABLES["public_address"]["default"] = $possible_addresses.first + $CONFIGURABLES["hostname"]["default"] = Socket.gethostname + $CONFIGURABLES["banner"]["default"] = "Mu Master at #{$CONFIGURABLES["public_address"]["default"]}" + if $IN_AWS + # XXX move this crap to a callback hook for puttering around in the AWS submenu + aws = JSON.parse(open("http://169.254.169.254/latest/dynamic/instance-identity/document").read) + iam = nil + begin + iam = open("http://169.254.169.254/latest/meta-data/iam/security-credentials").read + rescue OpenURI::HTTPError, SocketError + end + # $CONFIGURABLES["aws"]["subtree"]["account_number"]["default"] = aws["accountId"] + $CONFIGURABLES["aws"]["subtree"]["region"]["default"] = aws["region"] + if iam and iam.size > 0 + # XXX can we think of a good way to test our permission set? + $CONFIGURABLES["aws"]["subtree"]["access_key"]["desc"] = $CONFIGURABLES["aws"]["subtree"]["access_key"]["desc"] + ". Not necessary if IAM Profile #{iam.bold} has sufficient API access." + $CONFIGURABLES["aws"]["subtree"]["access_secret"]["desc"] = $CONFIGURABLES["aws"]["subtree"]["access_key"]["desc"] + ". Not necessary if IAM Profile #{iam.bold} has sufficient API access." + end end + $CONFIGURABLES["aws"]["subtree"]["log_bucket_name"]["default"] = $CONFIGURABLES["hostname"]["default"] + $CONFIGURABLES["google"]["subtree"]["log_bucket_name"]["default"] = $CONFIGURABLES["hostname"]["default"] end - $CONFIGURABLES["aws"]["subtree"]["log_bucket_name"]["default"] = $CONFIGURABLES["hostname"]["default"] - $CONFIGURABLES["google"]["subtree"]["log_bucket_name"]["default"] = $CONFIGURABLES["hostname"]["default"] -end -def runValueCallback(desc, val) - if desc['array'] - if desc["callback"] - newval = [] - val.each { |v| - v = send(desc["callback"].to_sym, v) - newval << v if !v.nil? - } - val = newval - end - elsif desc["callback"] - val = send(desc["callback"].to_sym, val) - end - val -end - -def importCLIValues - $CONFIGURABLES.each_pair { |key, data| - next if !AMROOT and data['rootonly'] - if data.has_key?("subtree") - if !data['named_subentries'] - data["subtree"].each_pair { |subkey, subdata| - next if !AMROOT and subdata['rootonly'] - if $opts[(subdata['cli-opt'].gsub(/-/, "_")+"_given").to_sym] - newval = runValueCallback(subdata, $opts[subdata['cli-opt'].gsub(/-/, "_").to_sym]) - subdata["value"] = newval if !newval.nil? - $CHANGES.concat(subdata['changes']) if subdata['changes'] - end + def runValueCallback(desc, val) + if desc['array'] + if desc["callback"] + newval = [] + val.each { |v| + v = send(desc["callback"].to_sym, v) + newval << v if !v.nil? } + val = newval end - else - if $opts[(data['cli-opt'].gsub(/-/, "_")+"_given").to_sym] - newval = runValueCallback(data, $opts[data['cli-opt'].gsub(/-/, "_").to_sym]) - data["value"] = newval if !newval.nil? - $CHANGES.concat(data['changes']) if data['changes'] - end + elsif desc["callback"] + val = send(desc["callback"].to_sym, val) end - } -end + val + end -# Load values from our existing configuration into the $CONFIGURABLES hash -def importCurrentValues - require File.realpath(File.expand_path(File.dirname(__FILE__)+"/mu-load-config.rb")) - $CONFIGURABLES.each_key { |key| - next if !$MU_CFG.has_key?(key) - if $CONFIGURABLES[key].has_key?("subtree") - # It's a sub-tree. I'm too lazy to write a recursive thing for this, just - # cover the simple case that we actually care about for now. - if $CONFIGURABLES[key]["named_subentries"] - $CONFIGURABLES[key]['subtree']["#title"] = $CONFIGURABLES[key]['title'] - $MU_CFG[key].each_pair { |nameentry, subtree| - $CONFIGURABLES[key]['subtree']["#entries"] ||= {} - $CONFIGURABLES[key]['subtree']["#entries"][nameentry] = cloneHash($CONFIGURABLES[key]['subtree']) - $CONFIGURABLES[key]['subtree']["#entries"][nameentry].delete("#entries") - $CONFIGURABLES[key]["subtree"]["#entries"][nameentry]["name"] = { - "title" => "Name", - "desc" => "A name/alias for this account.", - "required" => true, - "value" => nameentry + def importCLIValues + $CONFIGURABLES.each_pair { |key, data| + next if !AMROOT and data['rootonly'] + if data.has_key?("subtree") + + if !data['named_subentries'] + data["subtree"].each_pair { |subkey, subdata| + next if !AMROOT and subdata['rootonly'] + if $opts[(subdata['cli-opt'].+"_given").to_sym] + newval = runValueCallback(subdata, $opts[subdata['cli-opt'].to_sym]) + subdata["value"] = newval if !newval.nil? + $CHANGES.concat(subdata['changes']) if subdata['changes'] + end } - $CONFIGURABLES[key]["subtree"].keys.each { |subkey| - next if !subtree.has_key?(subkey) - $CONFIGURABLES[key]["subtree"]["#entries"][nameentry][subkey]["value"] = subtree[subkey] + # Honor CLI adds for named trees (credentials, etc) if there are no + # entries in them yet. + elsif data["#entries"].nil? or data["#entries"].empty? + newvals = false + data["subtree"].each_pair { |subkey, subdata| + next if !AMROOT and subdata['rootonly'] + next if !subdata['cli-opt'] + if $opts[(subdata['cli-opt']+"_given").to_sym] + newval = runValueCallback(subdata, $opts[subdata['cli-opt'].to_sym]) + if !newval.nil? + subdata["value"] = newval + newvals = true + end + end } - } + if newvals + newtree = data["subtree"].dup + newtree['default']['value'] = true if newtree['default'] + data['subtree']['#entries'] = { + "default" => newtree + } + end + end else - $CONFIGURABLES[key]["subtree"].keys.each { |subkey| - next if !$MU_CFG[key].has_key?(subkey) - $CONFIGURABLES[key]["subtree"][subkey]["value"] = $MU_CFG[key][subkey] - } + if $opts[(data['cli-opt']+"_given").to_sym] + newval = runValueCallback(data, $opts[data['cli-opt'].to_sym]) + data["value"] = newval if !newval.nil? + $CHANGES.concat(data['changes']) if data['changes'] + end end - else - $CONFIGURABLES[key]["value"] = $MU_CFG[key] - end - } -end + } + end -def printVal(data) - valid = true - valid = validate(data["value"], data, false) if data["value"] - value = if data["value"] and data["value"] != "" - data["value"] - elsif data["default"] and data["default"] != "" - data["default"] - end - if data['readonly'] and value - print " - "+value.to_s.cyan.on_black - elsif !valid - print " "+data["value"].to_s.red.on_black - print " (consider default of #{data["default"].to_s.bold})" if data["default"] - elsif !data["value"].nil? - print " - "+data["value"].to_s.green.on_black - elsif data["required"] - print " - "+"REQUIRED".red.on_black - elsif !data["default"].nil? - print " - "+data["default"].to_s.yellow.on_black+" (DEFAULT)" + def printVal(data) + valid = true + valid = validate(data["value"], data, false) if data["value"] + + value = if data["value"] and data["value"] != "" + data["value"] + elsif data["default"] and data["default"] != "" + data["default"] + end + if data['readonly'] and value + print " - "+value.to_s.cyan.on_black + elsif !valid + print " "+data["value"].to_s.red.on_black + print " (consider default of #{data["default"].to_s.bold})" if data["default"] + elsif !data["value"].nil? + print " - "+data["value"].to_s.green.on_black + elsif data["required"] + print " - "+"REQUIRED".red.on_black + elsif !data["default"].nil? + print " - "+data["default"].to_s.yellow.on_black+" (DEFAULT)" + end end -end -# Converts the current $CONFIGURABLES object to a Hash suitable for merging -# with $MU_CFG. -def setConfigTree(tree = $CONFIGURABLES) - cfg = {} - tree.each_pair { |key, data| - next if !AMROOT and data['rootonly'] - if data.has_key?("subtree") - if data["named_subentries"] - if data["subtree"]["#entries"] - data["subtree"]["#entries"].each_pair { |name, block| - - next if !block.is_a?(Hash) - block.each_pair { |subkey, subdata| - next if subkey.match(/^#/) or !subdata.is_a?(Hash) - cfg[key] ||= {} - cfg[key][name] ||= {} - cfg[key][name][subkey] = subdata['value'] if subdata['value'] + # Converts the current $CONFIGURABLES object to a Hash suitable for merging + # with $MU_CFG. + def setConfigTree(tree = $CONFIGURABLES) + cfg = {} + tree.each_pair { |key, data| + next if !AMROOT and data['rootonly'] + if data.has_key?("subtree") + if data["named_subentries"] + if data["subtree"]["#entries"] + data["subtree"]["#entries"].each_pair { |name, block| + + next if !block.is_a?(Hash) + block.each_pair { |subkey, subdata| + next if subkey.match(/^#/) or !subdata.is_a?(Hash) + cfg[key] ||= {} + cfg[key][name] ||= {} + cfg[key][name][subkey] = subdata['value'] if subdata['value'] + } } + end + else + data["subtree"].each_pair { |subkey, subdata| + if !subdata["value"].nil? + cfg[key] ||= {} + cfg[key][subkey] = subdata["value"] + elsif !subdata["default"].nil? and !$HAVE_GLOBAL_CONFIG or ($MU_CFG and (!$MU_CFG[key] or !$MU_CFG[key][subkey])) + cfg[key] ||= {} + cfg[key][subkey] = subdata["default"] + end } end - else - data["subtree"].each_pair { |subkey, subdata| - if !subdata["value"].nil? - cfg[key] ||= {} - cfg[key][subkey] = subdata["value"] - elsif !subdata["default"].nil? and !$HAVE_GLOBAL_CONFIG or ($MU_CFG and (!$MU_CFG[key] or !$MU_CFG[key][subkey])) - cfg[key] ||= {} - cfg[key][subkey] = subdata["default"] - end - } + elsif !data["value"].nil? + cfg[key] = data["value"] + elsif !data["default"].nil? and !$HAVE_GLOBAL_CONFIG or ($MU_CFG and !$MU_CFG[key]) + cfg[key] = data["default"] end - elsif !data["value"].nil? - cfg[key] = data["value"] - elsif !data["default"].nil? and !$HAVE_GLOBAL_CONFIG or ($MU_CFG and !$MU_CFG[key]) - cfg[key] = data["default"] - end - } - cfg -end + } + cfg + end -def displayCurrentOpts(tree = $CONFIGURABLES) - count = 1 - optlist = [] - tree.each_pair { |key, data| - next if !data.is_a?(Hash) - next if !AMROOT and data['rootonly'] - if data["title"].nil? or data["#menu"].nil? - next - end - print data["#menu"].bold+") "+data["title"] - if data.has_key?("subtree") - puts "" - if data["named_subentries"] - if data['subtree']['#entries'] - data['subtree']['#entries'].each_pair { |nameentry, subdata| - next if nameentry.match(/^#/) - puts " "+subdata["#menu"].bold+". "+nameentry.green.on_black + def displayCurrentOpts(tree = $CONFIGURABLES) + count = 1 + optlist = [] + tree.each_pair { |key, data| + next if !data.is_a?(Hash) + next if !AMROOT and data['rootonly'] + if data["title"].nil? or data["#menu"].nil? + next + end + print data["#menu"].bold+") "+data["title"] + if data.has_key?("subtree") + puts "" + if data["named_subentries"] + if data['subtree']['#entries'] + data['subtree']['#entries'].each_pair { |nameentry, subdata| + next if nameentry.match(/^#/) + puts " "+subdata["#menu"].bold+". "+nameentry.green.on_black + } + end + else + data["subtree"].each_pair { |subkey, subdata| + next if !AMROOT and subdata['rootonly'] + print " "+subdata["#menu"].bold+". "+subdata["title"] + printVal(subdata) + puts "" } end else - data["subtree"].each_pair { |subkey, subdata| - next if !AMROOT and subdata['rootonly'] - print " "+subdata["#menu"].bold+". "+subdata["title"] - printVal(subdata) - puts "" - } + printVal(data) + puts "" end - else - printVal(data) - puts "" - end - count = count + 1 - } - optlist -end + count = count + 1 + } + optlist + end -############################################################################### - -trap("INT"){ puts "" ; exit } -importCurrentValues if !$INITIALIZE or $HAVE_GLOBAL_CONFIG or $IN_GEM -importCLIValues -setDefaults -assignMenuEntries # populates $MENU_MAP - -def ask(desc) - puts "" - puts (desc['required'] ? "REQUIRED".red.on_black : "OPTIONAL".yellow.on_black)+" - "+desc["desc"] - puts "Enter one or more values, separated by commas".yellow.on_black if desc['array'] - puts "Enter 0 or false, 1 or true".yellow.on_black if desc['boolean'] - prompt = desc["title"].bold + "> " - current = desc['value'] || desc['default'] - if current - current = current.join(", ") if desc['array'] and current.is_a?(Array) - Readline.pre_input_hook = -> do - Readline.insert_text current.to_s - Readline.redisplay - Readline.pre_input_hook = nil + ############################################################################### + + trap("INT"){ puts "" ; exit } + importCurrentValues if !$INITIALIZE or $HAVE_GLOBAL_CONFIG or $IN_GEM + importCLIValues + setDefaults + assignMenuEntries # populates $MENU_MAP + + def ask(desc) + puts "" + puts (desc['required'] ? "REQUIRED".red.on_black : "OPTIONAL".yellow.on_black)+" - "+desc["desc"] + puts "Enter one or more values, separated by commas".yellow.on_black if desc['array'] + puts "Enter 0 or false, 1 or true".yellow.on_black if desc['boolean'] + prompt = desc["title"].bold + "> " + current = desc['value'] || desc['default'] + if current + current = current.join(", ") if desc['array'] and current.is_a?(Array) + Readline.pre_input_hook = -> do + Readline.insert_text current.to_s + Readline.redisplay + Readline.pre_input_hook = nil + end + end + val = Readline.readline(prompt, false) + if desc['array'] and !val.nil? + val = val.strip.split(/\s*,\s*/) end + if desc['boolean'] + val = false if ["0", "false", "FALSE"].include?(val) + val = true if ["1", "true", "TRUE"].include?(val) end - val = Readline.readline(prompt, false) - if desc['array'] and !val.nil? - val = val.strip.split(/\s*,\s*/) + val = runValueCallback(desc, val) + val = current if val.nil? and desc['value'] + val end - if desc['boolean'] - val = false if ["0", "false", "FALSE"].include?(val) - val = true if ["1", "true", "TRUE"].include?(val) - end - val = runValueCallback(desc, val) - val = current if val.nil? and desc['value'] - val -end -def validate(newval, reqs, addnewline = true, in_use: []) - ok = true - def validate_individual_value(newval, reqs, addnewline, in_use: []) + def validate(newval, reqs, addnewline = true, in_use: []) ok = true - if reqs['boolean'] and newval != true and newval != false and newval != nil - puts "\nInvalid value '#{newval.bold}' for #{reqs['title'].bold} (must be true or false)".light_red.on_black - puts "\n\n" if addnewline - ok = false - elsif in_use and in_use.size > 0 and in_use.include?(newval) - puts "\n##{reqs['title'].bold} #{newval} not available".light_red.on_black - puts "\n\n" if addnewline - ok = false - elsif reqs['pattern'] - if newval.nil? - puts "\nSupplied value for #{reqs['title'].bold} did not pass validation".light_red.on_black + def validate_individual_value(newval, reqs, addnewline, in_use: []) + ok = true + if reqs['boolean'] and newval != true and newval != false and newval != nil + puts "\nInvalid value '#{newval.bold}' for #{reqs['title'].bold} (must be true or false)".light_red.on_black puts "\n\n" if addnewline ok = false - elsif reqs['negate_pattern'] - if newval.to_s.match(reqs['pattern']) - puts "\nInvalid value '#{newval.bold}' for #{reqs['title'].bold} (must NOT match #{reqs['pattern']})".light_red.on_black + elsif in_use and in_use.size > 0 and in_use.include?(newval) + puts "\n##{reqs['title'].bold} #{newval} not available".light_red.on_black + puts "\n\n" if addnewline + ok = false + elsif reqs['pattern'] + if newval.nil? + puts "\nSupplied value for #{reqs['title'].bold} did not pass validation".light_red.on_black + puts "\n\n" if addnewline + ok = false + elsif reqs['negate_pattern'] + if newval.to_s.match(reqs['pattern']) + puts "\nInvalid value '#{newval.bold}' for #{reqs['title'].bold} (must NOT match #{reqs['pattern']})".light_red.on_black + puts "\n\n" if addnewline + ok = false + end + elsif !newval.to_s.match(reqs['pattern']) + puts "\nInvalid value '#{newval.bold}' #{reqs['title'].bold} (must match #{reqs['pattern']})".light_red.on_black puts "\n\n" if addnewline ok = false end - elsif !newval.to_s.match(reqs['pattern']) - puts "\nInvalid value '#{newval.bold}' #{reqs['title'].bold} (must match #{reqs['pattern']})".light_red.on_black - puts "\n\n" if addnewline - ok = false end + ok end - ok - end - if reqs['array'] - if !newval.is_a?(Array) - puts "\nInvalid value '#{newval.bold}' for #{reqs['title'].bold} (should be an array)".light_red.on_black - puts "\n\n" if addnewline - ok = false + if reqs['array'] + if !newval.is_a?(Array) + puts "\nInvalid value '#{newval.bold}' for #{reqs['title'].bold} (should be an array)".light_red.on_black + puts "\n\n" if addnewline + ok = false + else + newval.each { |v| + ok = false if !validate_individual_value(v, reqs, addnewline, in_use: in_use) + } + end else - newval.each { |v| - ok = false if !validate_individual_value(v, reqs, addnewline, in_use: in_use) - } + ok = false if !validate_individual_value(newval, reqs, addnewline, in_use: in_use) end - else - ok = false if !validate_individual_value(newval, reqs, addnewline, in_use: in_use) + ok end - ok -end -answer = nil -changed = false + answer = nil + changed = false -def entireConfigValid? - ok = true - $CONFIGURABLES.each_pair { |key, data| - next if !AMROOT and data['rootonly'] - if data.has_key?("subtree") - data["subtree"].each_pair { |subkey, subdata| - next if !AMROOT and subdata['rootonly'] + def entireConfigValid? + ok = true + $CONFIGURABLES.each_pair { |key, data| + next if !AMROOT and data['rootonly'] + if data.has_key?("subtree") + data["subtree"].each_pair { |subkey, subdata| + next if !AMROOT and subdata['rootonly'] + next if !data["value"] + ok = false if !validate(data["value"], data, false) + } + else next if !data["value"] ok = false if !validate(data["value"], data, false) - } - else - next if !data["value"] - ok = false if !validate(data["value"], data, false) - end - } - ok -end + end + } + ok + end -def generateMiniMenu(srctree) - map = {} - tree = cloneHash(srctree) - return [tree, map] -end + def generateMiniMenu(srctree) + map = {} + tree = cloneHash(srctree) + return [tree, map] + end -def menu(tree = $CONFIGURABLES, map = $MENU_MAP, submenu_name = nil, in_use_names = []) - begin - optlist = displayCurrentOpts(tree) + def menu(tree = $CONFIGURABLES, map = $MENU_MAP, submenu_name = nil, in_use_names = []) begin - if submenu_name - print "Enter an option to change, "+"O".bold+" to save #{submenu_name.bold}, or "+"q".bold+" to return.\n> " - else - print "Enter an option to change, "+"O".bold+" to save this config, or "+"^D".bold+" to quit.\n> " - end - answer = gets - if answer.nil? + optlist = displayCurrentOpts(tree) + begin + if submenu_name + print "Enter an option to change, "+"O".bold+" to save #{submenu_name.bold}, or "+"q".bold+" to return.\n> " + else + print "Enter an option to change, "+"O".bold+" to save this config, or "+"^D".bold+" to quit.\n> " + end + answer = gets + if answer.nil? + puts "" + exit 0 + end + answer.strip! + rescue EOFError puts "" exit 0 end - answer.strip! - rescue EOFError - puts "" - exit 0 - end - if map.has_key?(answer) and map[answer]["#addnew"] - minimap = {} - assignMenuEntries(map[answer], minimap) - newtree, newmap = menu( - map[answer], - minimap, - map[answer]['#title']+" (NEW)", - if map[answer]['#entries'] - map[answer]['#entries'].keys.reject { |k| k.match(/^#/) } + if map.has_key?(answer) and map[answer]["#addnew"] + minimap = {} + assignMenuEntries(map[answer], minimap) + newtree, newmap = menu( + map[answer], + minimap, + map[answer]['#title']+" (NEW)", + if map[answer]['#entries'] + map[answer]['#entries'].keys.reject { |k| k.match(/^#/) } + end + ) + if newtree + newname = newtree["name"]["value"] + newtree.delete("#addnew") + parentname = map[answer]['#key'] + + tree[parentname]['subtree'] ||= {} + tree[parentname]['subtree']['#entries'] ||= {} + # if we're in cloud land and just added a 2nd entry, set the original + # one to 'default' + if tree[parentname]['subtree']['#entries'].size == 1 + end + tree[parentname]['subtree']['#entries'][newname] = cloneHash(newtree) + + map = {} # rebuild the menu map to include new entries + assignMenuEntries(tree, map) end - ) - if newtree - newname = newtree["name"]["value"] - newtree.delete("#addnew") + # exit + # map[answer] = newtree if newtree + elsif map.has_key?(answer) and map[answer]["is_submenu"] + minimap = {} parentname = map[answer]['#key'] - - tree[parentname]['subtree'] ||= {} - tree[parentname]['subtree']['#entries'] ||= {} - # if we're in cloud land and just added a 2nd entry, set the original - # one to 'default' - if tree[parentname]['subtree']['#entries'].size == 1 + entryname = map[answer]['#title'] + puts PP.pp(map[answer], '').yellow + puts PP.pp(tree[parentname]['subtree']['#entries'][entryname], '').red + assignMenuEntries(tree[parentname]['subtree']['#entries'][entryname], minimap) + newtree, newmap = menu( + map[answer], + minimap, + map[answer]["#title"], + (map[answer]['#entries'].keys - [map[answer]['#title']]) + ) + map[answer] = newtree if newtree + elsif map.has_key?(answer) and !map[answer].has_key?("subtree") + newval = ask(map[answer]) + if !validate(newval, map[answer], in_use: in_use_names) + sleep 1 + next end - tree[parentname]['subtree']['#entries'][newname] = cloneHash(newtree) - - map = {} # rebuild the menu map to include new entries - assignMenuEntries(tree, map) - end -# exit -# map[answer] = newtree if newtree - elsif map.has_key?(answer) and map[answer]["is_submenu"] - minimap = {} - parentname = map[answer]['#key'] - entryname = map[answer]['#title'] - puts PP.pp(map[answer], '').yellow - puts PP.pp(tree[parentname]['subtree']['#entries'][entryname], '').red - assignMenuEntries(tree[parentname]['subtree']['#entries'][entryname], minimap) - newtree, newmap = menu( - map[answer], - minimap, - map[answer]["#title"], - (map[answer]['#entries'].keys - [map[answer]['#title']]) - ) - map[answer] = newtree if newtree - elsif map.has_key?(answer) and !map[answer].has_key?("subtree") - newval = ask(map[answer]) - if !validate(newval, map[answer], in_use: in_use_names) + map[answer]['value'] = newval == "" ? nil : newval + tree[map[answer]['#key']]['value'] = newval + $CHANGES.concat(map[answer]['changes']) if map[answer].include?("changes") + if map[answer]['title'] == "Local Hostname" + # $CONFIGURABLES["aws"]["subtree"]["log_bucket_name"]["default"] = newval + # $CONFIGURABLES["google"]["subtree"]["log_bucket_name"]["default"] = newval + elsif map[answer]['title'] == "Public Address" + $CONFIGURABLES["banner"]["default"] = "Mu Master at #{newval}" + end + changed = true + puts "" + elsif ["q", "Q"].include?(answer) + return nil + elsif !["", "0", "O", "o"].include?(answer) + puts "\nInvalid option '#{answer.bold}'".light_red.on_black+"\n\n" sleep 1 - next - end - map[answer]['value'] = newval == "" ? nil : newval - tree[map[answer]['#key']]['value'] = newval - $CHANGES.concat(map[answer]['changes']) if map[answer].include?("changes") - if map[answer]['title'] == "Local Hostname" -# $CONFIGURABLES["aws"]["subtree"]["log_bucket_name"]["default"] = newval -# $CONFIGURABLES["google"]["subtree"]["log_bucket_name"]["default"] = newval - elsif map[answer]['title'] == "Public Address" - $CONFIGURABLES["banner"]["default"] = "Mu Master at #{newval}" + else + answer = nil if !entireConfigValid? end - changed = true - puts "" - elsif ["q", "Q"].include?(answer) - return nil - elsif !["", "0", "O", "o"].include?(answer) - puts "\nInvalid option '#{answer.bold}'".light_red.on_black+"\n\n" - sleep 1 - else - answer = nil if !entireConfigValid? - end - end while answer != "0" and answer != "O" and answer != "o" + end while answer != "0" and answer != "O" and answer != "o" - return [tree, map] -end + return [tree, map] + end -if !$opts[:noninteractive] - $CONFIGURABLES, $MENU_MAP = menu - $MU_CFG = setConfigTree -else - if !entireConfigValid? - puts "Configuration had validation errors, exiting.\nRe-invoke #{$0} to correct." - exit 1 + if !$opts[:noninteractive] + $CONFIGURABLES, $MENU_MAP = menu + $MU_CFG = setConfigTree + else + $MU_CFG = setConfigTree + if !entireConfigValid? + puts "Configuration had validation errors, exiting.\nRe-invoke #{$0} to correct." + exit 1 + end end -end -if AMROOT - newcfg = cloneHash($MU_CFG) - require File.realpath(File.expand_path(File.dirname(__FILE__)+"/mu-load-config.rb")) - newcfg['multiuser'] = true - saveMuConfig(newcfg) - $MU_CFG = loadMuConfig($MU_SET_DEFAULTS) -end + if AMROOT + newcfg = cloneHash($MU_CFG) + require File.realpath(File.expand_path(File.dirname(__FILE__)+"/mu-load-config.rb")) + newcfg['multiuser'] = true + saveMuConfig(newcfg) + $MU_CFG = loadMuConfig($MU_SET_DEFAULTS) + end -def set389DSCreds - require 'mu' - credlist = { - "bind_creds" => { - "user" => "CN=mu_bind_creds,#{$MU_CFG["ldap"]['user_ou']}" - }, - "join_creds" => { - "user" => "CN=mu_join_creds,#{$MU_CFG["ldap"]['user_ou']}" - }, - "cfg_directory_adm" => { - "user" => "admin" - }, - "root_dn_user" => { - "user" => "CN=root_dn_user" + def set389DSCreds + require 'mu' + credlist = { + "bind_creds" => { + "user" => "CN=mu_bind_creds,#{$MU_CFG["ldap"]['user_ou']}" + }, + "join_creds" => { + "user" => "CN=mu_join_creds,#{$MU_CFG["ldap"]['user_ou']}" + }, + "cfg_directory_adm" => { + "user" => "admin" + }, + "root_dn_user" => { + "user" => "CN=root_dn_user" + } } - } - credlist.each_pair { |creds, cfg| - begin - data = nil - if $MU_CFG["ldap"].has_key?(creds) - data = MU::Groomer::Chef.getSecret( - vault: $MU_CFG["ldap"][creds]["vault"], - item: $MU_CFG["ldap"][creds]["item"] - ) - MU::Groomer::Chef.grantSecretAccess("MU-MASTER", $MU_CFG["ldap"][creds]["vault"], $MU_CFG["ldap"][creds]["item"]) - else - data = MU::Groomer::Chef.getSecret(vault: "mu_ldap", item: creds) - MU::Groomer::Chef.grantSecretAccess("MU-MASTER", "mu_ldap", creds) - end - rescue MU::Groomer::Chef::MuNoSuchSecret - user = cfg["user"] - pw = Password.pronounceable(14..16) - if $MU_CFG["ldap"].has_key?(creds) - data = { - $MU_CFG["ldap"][creds]["username_field"] => user, - $MU_CFG["ldap"][creds]["password_field"] => pw - } - MU::Groomer::Chef.saveSecret( - vault: $MU_CFG["ldap"][creds]["vault"], - item: $MU_CFG["ldap"][creds]["item"], - data: data, - permissions: "name:MU-MASTER" - ) - else - MU::Groomer::Chef.saveSecret( - vault: "mu_ldap", - item: creds, - data: { "username" => user, "password" => pw }, - permissions: "name:MU-MASTER" - ) + credlist.each_pair { |creds, cfg| + begin + data = nil + if $MU_CFG["ldap"].has_key?(creds) + data = MU::Groomer::Chef.getSecret( + vault: $MU_CFG["ldap"][creds]["vault"], + item: $MU_CFG["ldap"][creds]["item"] + ) + MU::Groomer::Chef.grantSecretAccess("MU-MASTER", $MU_CFG["ldap"][creds]["vault"], $MU_CFG["ldap"][creds]["item"]) + else + data = MU::Groomer::Chef.getSecret(vault: "mu_ldap", item: creds) + MU::Groomer::Chef.grantSecretAccess("MU-MASTER", "mu_ldap", creds) + end + rescue MU::Groomer::MuNoSuchSecret + user = cfg["user"] + pw = Password.pronounceable(14..16) + if $MU_CFG["ldap"].has_key?(creds) + data = { + $MU_CFG["ldap"][creds]["username_field"] => user, + $MU_CFG["ldap"][creds]["password_field"] => pw + } + MU::Groomer::Chef.saveSecret( + vault: $MU_CFG["ldap"][creds]["vault"], + item: $MU_CFG["ldap"][creds]["item"], + data: data, + permissions: "name:MU-MASTER" + ) + else + MU::Groomer::Chef.saveSecret( + vault: "mu_ldap", + item: creds, + data: { "username" => user, "password" => pw }, + permissions: "name:MU-MASTER" + ) + end end - end - } -end - -if AMROOT - cur_chef_version = `/bin/rpm -q chef`.sub(/^chef-(\d+\.\d+\.\d+-\d+)\..*/, '\1').chomp - pref_chef_version = File.read("#{MU_BASE}/var/mu-chef-client-version").chomp - if (cur_chef_version != pref_chef_version and cur_chef_version.sub(/\-\d+$/, "") != pref_chef_version) or cur_chef_version.match(/is not installed/) - puts "Updating MU-MASTER's Chef Client to '#{pref_chef_version}' from '#{cur_chef_version}'" - chef_installer = open("https://omnitruck.chef.io/install.sh").read - File.open("#{HOMEDIR}/chef-install.sh", File::CREAT|File::TRUNC|File::RDWR, 0644){ |f| - f.puts chef_installer } - system("/bin/rm -rf /opt/chef ; sh #{HOMEDIR}/chef-install.sh -v #{pref_chef_version}"); - # This will go fix gems, permissions, etc - system("/opt/chef/bin/chef-apply #{MU_BASE}/lib/cookbooks/mu-master/recipes/init.rb"); end -end -if $INITIALIZE - if AMROOT - %x{/sbin/service iptables stop} # Chef run will set up correct rules later + if AMROOT and !$IN_GEM + cur_chef_version = `/bin/rpm -q chef`.sub(/^chef-(\d+\.\d+\.\d+-\d+)\..*/, '\1').chomp + pref_chef_version = File.read("#{MU_BASE}/var/mu-chef-client-version").chomp + if (cur_chef_version != pref_chef_version and cur_chef_version.sub(/\-\d+$/, "") != pref_chef_version) or cur_chef_version.match(/is not installed/) + puts "Updating MU-MASTER's Chef Client to '#{pref_chef_version}' from '#{cur_chef_version}'" + chef_installer = open("https://omnitruck.chef.io/install.sh").read + File.open("#{HOMEDIR}/chef-install.sh", File::CREAT|File::TRUNC|File::RDWR, 0644){ |f| + f.puts chef_installer + } + system("/bin/rm -rf /opt/chef ; sh #{HOMEDIR}/chef-install.sh -v #{pref_chef_version}"); + # This will go fix gems, permissions, etc + system("/opt/chef/bin/chef-apply #{MU_BASE}/lib/cookbooks/mu-master/recipes/init.rb"); + end end - $MU_SET_DEFAULTS = setConfigTree - require File.realpath(File.expand_path(File.dirname(__FILE__)+"/mu-load-config.rb")) -else - if AMROOT - $NEW_CFG = $MU_CFG.merge(setConfigTree) + + if $INITIALIZE + if AMROOT and !$IN_GEM + %x{/sbin/service iptables stop} # Chef run will set up correct rules later + end + $MU_SET_DEFAULTS = setConfigTree + require File.realpath(File.expand_path(File.dirname(__FILE__)+"/mu-load-config.rb")) + saveMuConfig($MU_SET_DEFAULTS) else - $NEW_CFG = setConfigTree + if AMROOT + $NEW_CFG = $MU_CFG.merge(setConfigTree) + else + $NEW_CFG = setConfigTree + end + saveMuConfig($NEW_CFG) + $MU_CFG = $MU_CFG.merge(setConfigTree) + require File.realpath(File.expand_path(File.dirname(__FILE__)+"/mu-load-config.rb")) + end + begin + require 'mu' + rescue MU::MuError => e + puts "Correct the above error before proceeding. To retry, run:\n\n#{$0.bold} #{ARGV.join(" ").bold}" + exit 1 + rescue LoadError + system("cd #{MU_BASE}/lib/modules && umask 0022 && /usr/local/ruby-current/bin/bundle install") + require 'mu' end - saveMuConfig($NEW_CFG) - $MU_CFG = $MU_CFG.merge(setConfigTree) - require File.realpath(File.expand_path(File.dirname(__FILE__)+"/mu-load-config.rb")) -end -begin - require 'mu' -rescue MU::MuError => e - puts "Correct the above error before proceeding. To retry, run:\n\n#{$0.bold} #{ARGV.join(" ").bold}" - exit 1 -rescue LoadError - system("cd #{MU_BASE}/lib/modules && umask 0022 && /usr/local/ruby-current/bin/bundle install") - require 'mu' -end -if $IN_GEM - puts $MU_CFG.to_yaml - saveMuConfig($MU_CFG) - exit -end + if $IN_GEM + if $INITIALIZE + $MU_CFG = MU.detectCloudProviders + end + require 'mu/master/ssl' + MU::Master::SSL.bootstrap + puts $MU_CFG.to_yaml + saveMuConfig($MU_CFG) + MU::MommaCat.restart + exit + end -if AMROOT and ($INITIALIZE or $CHANGES.include?("hostname")) - system("/bin/hostname #{$MU_CFG['hostname']}") -end + if AMROOT and ($INITIALIZE or $CHANGES.include?("hostname")) + system("/bin/hostname #{$MU_CFG['hostname']}") + end -# Do some more basic-but-Chef-dependent configuration *before* we meddle with -# the Chef Server configuration, which depends on some of this (SSL certs and -# local firewall ports). -if AMROOT and ($INITIALIZE or $CHANGES.include?("chefartifacts")) - MU.log "Purging and re-uploading all Chef artifacts", MU::NOTICE - %x{/sbin/service iptables stop} if $INITIALIZE - output = %x{MU_INSTALLDIR=#{MU_BASE} MU_LIBDIR=#{MU_BASE}/lib MU_DATADIR=#{MU_BASE}/var #{MU_BASE}/lib/bin/mu-upload-chef-artifacts} - if $?.exitstatus != 0 - puts output - MU.log "mu-upload-chef-artifacts failed, can't proceed", MU::ERR + # Do some more basic-but-Chef-dependent configuration *before* we meddle with + # the Chef Server configuration, which depends on some of this (SSL certs and + # local firewall ports). + if AMROOT and ($INITIALIZE or $CHANGES.include?("chefartifacts")) + MU.log "Purging and re-uploading all Chef artifacts", MU::NOTICE + %x{/sbin/service iptables stop} if $INITIALIZE + output = %x{MU_INSTALLDIR=#{MU_BASE} MU_LIBDIR=#{MU_BASE}/lib MU_DATADIR=#{MU_BASE}/var #{MU_BASE}/lib/bin/mu-upload-chef-artifacts} + if $?.exitstatus != 0 + puts output + MU.log "mu-upload-chef-artifacts failed, can't proceed", MU::ERR + %x{/sbin/service iptables start} if !$INITIALIZE + exit 1 + end %x{/sbin/service iptables start} if !$INITIALIZE - exit 1 end - %x{/sbin/service iptables start} if !$INITIALIZE -end -if $INITIALIZE and AMROOT - MU.log "Force open key firewall holes", MU::NOTICE - system("chef-client -o 'recipe[mu-master::firewall-holes]'") -end - -if AMROOT - MU.log "Checking internal SSL signing authority and certificates", MU::NOTICE - if !system("chef-client -o 'recipe[mu-master::ssl-certs]'") and $INITIALIZE - MU.log "Got bad exit code trying to run recipe[mu-master::ssl-certs]', aborting", MU::ERR - exit 1 + if $INITIALIZE and AMROOT + MU.log "Force open key firewall holes", MU::NOTICE + system("chef-client -o 'recipe[mu-master::firewall-holes]'") end -end -def updateChefRbs - user = AMROOT ? "mu" : Etc.getpwuid(Process.uid).name - chefuser = user.gsub(/\./, "") - templates = { HOMEDIR+"/.chef/knife.rb" => KNIFE_TEMPLATE } - Dir.mkdir(HOMEDIR+"/.chef") if !Dir.exists?(HOMEDIR+"/.chef") if AMROOT - templates["/etc/chef/client.rb"] = CLIENT_TEMPLATE - templates["/etc/opscode/pivotal.rb"] = PIVOTAL_TEMPLATE + MU.log "Checking internal SSL signing authority and certificates", MU::NOTICE + if !system("chef-client -o 'recipe[mu-master::ssl-certs]'") and $INITIALIZE + MU.log "Got bad exit code trying to run recipe[mu-master::ssl-certs]', aborting", MU::ERR + exit 1 + end + end + + def updateChefRbs + user = AMROOT ? "mu" : Etc.getpwuid(Process.uid).name + chefuser = user.gsub(/\./, "") + templates = { HOMEDIR+"/.chef/knife.rb" => KNIFE_TEMPLATE } + Dir.mkdir(HOMEDIR+"/.chef") if !Dir.exist?(HOMEDIR+"/.chef") + if AMROOT + templates["/etc/chef/client.rb"] = CLIENT_TEMPLATE + templates["/etc/opscode/pivotal.rb"] = PIVOTAL_TEMPLATE + end + templates.each_pair { |file, template| + erb = ERB.new(template) + processed = erb.result(binding) + tmpfile = file+".tmp."+Process.pid.to_s + File.open(tmpfile, File::CREAT|File::TRUNC|File::RDWR, 0644){ |f| + f.puts processed + } + if !File.size?(file) or File.read(tmpfile) != File.read(file) + File.rename(tmpfile, file) + MU.log "Updated #{file}", MU::NOTICE + $CHANGES << "chefcerts" + else + File.unlink(tmpfile) + end + } end - templates.each_pair { |file, template| - erb = ERB.new(template) - processed = erb.result(binding) - tmpfile = file+".tmp."+Process.pid.to_s + + + if AMROOT + erb = ERB.new(File.read("#{MU_BASE}/lib/cookbooks/mu-master/templates/default/chef-server.rb.erb")) + updated_server_cfg = erb.result(binding) + cfgpath = "/etc/opscode/chef-server.rb" + tmpfile = "/etc/opscode/chef-server.rb.#{Process.pid}" File.open(tmpfile, File::CREAT|File::TRUNC|File::RDWR, 0644){ |f| - f.puts processed + f.puts updated_server_cfg } - if !File.size?(file) or File.read(tmpfile) != File.read(file) - File.rename(tmpfile, file) - MU.log "Updated #{file}", MU::NOTICE + if !File.size?(cfgpath) or File.read(tmpfile) != File.read(cfgpath) + File.rename(tmpfile, cfgpath) + # Opscode can't seem to get things right with their postgres socket + Dir.mkdir("/var/run/postgresql", 0755) if !Dir.exist?("/var/run/postgresql") + if File.exist?("/tmp/.s.PGSQL.5432") and !File.exist?("/var/run/postgresql/.s.PGSQL.5432") + File.symlink("/tmp/.s.PGSQL.5432", "/var/run/postgresql/.s.PGSQL.5432") + elsif !File.exist?("/tmp/.s.PGSQL.5432") and File.exist?("/var/run/postgresql/.s.PGSQL.5432") + File.symlink("/var/run/postgresql/.s.PGSQL.5432", "/tmp/.s.PGSQL.5432") + end + MU.log "Chef Server config was modified, reconfiguring...", MU::NOTICE + # XXX Some undocumented port Chef needs only on startup is being blocked by + # iptables. Something rabbitmq-related. Dopey workaround. + %x{/sbin/service iptables stop} + system("/opt/opscode/bin/chef-server-ctl reconfigure") + system("/opt/opscode/bin/chef-server-ctl restart") + %x{/sbin/service iptables start} if !$INITIALIZE + updateChefRbs $CHANGES << "chefcerts" else File.unlink(tmpfile) + updateChefRbs end - } -end - - -if AMROOT - erb = ERB.new(File.read("#{MU_BASE}/lib/cookbooks/mu-master/templates/default/chef-server.rb.erb")) - updated_server_cfg = erb.result(binding) - cfgpath = "/etc/opscode/chef-server.rb" - tmpfile = "/etc/opscode/chef-server.rb.#{Process.pid}" - File.open(tmpfile, File::CREAT|File::TRUNC|File::RDWR, 0644){ |f| - f.puts updated_server_cfg - } - if !File.size?(cfgpath) or File.read(tmpfile) != File.read(cfgpath) - File.rename(tmpfile, cfgpath) - # Opscode can't seem to get things right with their postgres socket - Dir.mkdir("/var/run/postgresql", 0755) if !Dir.exists?("/var/run/postgresql") - if File.exists?("/tmp/.s.PGSQL.5432") and !File.exists?("/var/run/postgresql/.s.PGSQL.5432") - File.symlink("/tmp/.s.PGSQL.5432", "/var/run/postgresql/.s.PGSQL.5432") - elsif !File.exists?("/tmp/.s.PGSQL.5432") and File.exists?("/var/run/postgresql/.s.PGSQL.5432") - File.symlink("/var/run/postgresql/.s.PGSQL.5432", "/tmp/.s.PGSQL.5432") - end - MU.log "Chef Server config was modified, reconfiguring...", MU::NOTICE - # XXX Some undocumented port Chef needs only on startup is being blocked by - # iptables. Something rabbitmq-related. Dopey workaround. - %x{/sbin/service iptables stop} - system("/opt/opscode/bin/chef-server-ctl reconfigure") - system("/opt/opscode/bin/chef-server-ctl restart") - %x{/sbin/service iptables start} if !$INITIALIZE - updateChefRbs - $CHANGES << "chefcerts" else - File.unlink(tmpfile) updateChefRbs end -else - updateChefRbs -end -if $IN_AWS and AMROOT - system("#{MU_BASE}/lib/bin/mu-aws-setup --dns --sg --logs --ephemeral") -# XXX --ip? Do we really care? -end -if $IN_GOOGLE and AMROOT - system("#{MU_BASE}/lib/bin/mu-gcp-setup --sg --logs") -end - -if $INITIALIZE or $CHANGES.include?("chefcerts") - system("rm -f #{HOMEDIR}/.chef/trusted_certs/* ; knife ssl fetch -c #{HOMEDIR}/.chef/knife.rb") - if AMROOT - system("rm -f /etc/chef/trusted_certs/* ; knife ssl fetch -c /etc/chef/client.rb") + if $IN_AWS and AMROOT + system("#{MU_BASE}/lib/bin/mu-aws-setup --dns --sg --logs --ephemeral") + # XXX --ip? Do we really care? + end + if $IN_GOOGLE and AMROOT + system("#{MU_BASE}/lib/bin/mu-gcp-setup --sg --logs") + end + if $IN_AZURE and AMROOT + system("#{MU_BASE}/lib/bin/mu-azure-setup --sg") end -end -# knife ssl fetch isn't bright enough to nab our intermediate certs, which -# ironically becomes a problem when we use one from the real world. Jam it -# into knife and chef-client's faces thusly: -if $MU_CFG['ssl'] and $MU_CFG['ssl']['chain'] and File.size?($MU_CFG['ssl']['chain']) - cert = File.basename($MU_CFG['ssl']['chain']) - FileUtils.cp($MU_CFG['ssl']['chain'], HOMEDIR+"/.chef/trusted_certs/#{cert}") - File.chmod(0600, HOMEDIR+"/.chef/trusted_certs/#{cert}") - if AMROOT - File.chmod(0644, $MU_CFG['ssl']['chain']) - FileUtils.cp($MU_CFG['ssl']['chain'], "/etc/chef/trusted_certs/#{cert}") + if $INITIALIZE or $CHANGES.include?("chefcerts") + system("rm -f #{HOMEDIR}/.chef/trusted_certs/* ; knife ssl fetch -c #{HOMEDIR}/.chef/knife.rb") + if AMROOT + system("rm -f /etc/chef/trusted_certs/* ; knife ssl fetch -c /etc/chef/client.rb") + end end -end -if $MU_CFG['repos'] and $MU_CFG['repos'].size > 0 - $MU_CFG['repos'].each { |repo| - repo.match(/\/([^\/]+?)(\.git)?$/) - shortname = Regexp.last_match(1) - repodir = MU.dataDir + "/" + shortname - if !Dir.exists?(repodir) - MU.log "Cloning #{repo} into #{repodir}", MU::NOTICE - Dir.chdir(MU.dataDir) - system("/usr/bin/git clone #{repo}") - $CHANGES << "chefartifacts" + # knife ssl fetch isn't bright enough to nab our intermediate certs, which + # ironically becomes a problem when we use one from the real world. Jam it + # into knife and chef-client's faces thusly: + if $MU_CFG['ssl'] and $MU_CFG['ssl']['chain'] and File.size?($MU_CFG['ssl']['chain']) + cert = File.basename($MU_CFG['ssl']['chain']) + FileUtils.cp($MU_CFG['ssl']['chain'], HOMEDIR+"/.chef/trusted_certs/#{cert}") + File.chmod(0600, HOMEDIR+"/.chef/trusted_certs/#{cert}") + if AMROOT + File.chmod(0644, $MU_CFG['ssl']['chain']) + FileUtils.cp($MU_CFG['ssl']['chain'], "/etc/chef/trusted_certs/#{cert}") end - } -end + end -if !AMROOT - exit -end + if $MU_CFG['repos'] and $MU_CFG['repos'].size > 0 + $MU_CFG['repos'].each { |repo| + repo.match(/\/([^\/]+?)(\.git)?$/) + shortname = Regexp.last_match(1) + repodir = MU.dataDir + "/" + shortname + if !Dir.exist?(repodir) + MU.log "Cloning #{repo} into #{repodir}", MU::NOTICE + Dir.chdir(MU.dataDir) + system("/usr/bin/git clone #{repo}") + $CHANGES << "chefartifacts" + end + } + end -begin - MU::Groomer::Chef.getSecret(vault: "secrets", item: "consul") -rescue MU::Groomer::Chef::MuNoSuchSecret - data = { - "private_key" => File.read("#{MU_BASE}/var/ssl/consul.key"), - "certificate" => File.read("#{MU_BASE}/var/ssl/consul.crt"), - "ca_certificate" => File.read("#{MU_BASE}/var/ssl/Mu_CA.pem") - } - MU::Groomer::Chef.saveSecret( - vault: "secrets", - item: "consul", - data: data, - permissions: "name:MU-MASTER" - ) -end -if $INITIALIZE or $CHANGES.include?("vault") - MU.log "Setting up Hashicorp Vault", MU::NOTICE - system("chef-client -o 'recipe[mu-master::vault]'") -end + if !AMROOT + exit + end -if $MU_CFG['ldap']['type'] == "389 Directory Services" begin - MU::Master::LDAP.listUsers - rescue Exception => e # XXX lazy exception handling is lazy - $CHANGES << "389ds" + MU::Groomer::Chef.getSecret(vault: "secrets", item: "consul") + rescue MU::Groomer::MuNoSuchSecret + data = { + "private_key" => File.read("#{MU_BASE}/var/ssl/consul.key"), + "certificate" => File.read("#{MU_BASE}/var/ssl/consul.crt"), + "ca_certificate" => File.read("#{MU_BASE}/var/ssl/Mu_CA.pem") + } + MU::Groomer::Chef.saveSecret( + vault: "secrets", + item: "consul", + data: data, + permissions: "name:MU-MASTER" + ) end - if $INITIALIZE or $CHANGES.include?("389ds") - File.unlink("/root/389ds.tmp/389-directory-setup.inf") if File.exists?("/root/389ds.tmp/389-directory-setup.inf") - MU.log "Configuring 389 Directory Services", MU::NOTICE - set389DSCreds - system("chef-client -o 'recipe[mu-master::389ds]'") - exit 1 if $? != 0 - MU::Master::LDAP.initLocalLDAP - system("chef-client -o 'recipe[mu-master::sssd]'") - exit 1 if $? != 0 + if $INITIALIZE or $CHANGES.include?("vault") + MU.log "Setting up Hashicorp Vault", MU::NOTICE + system("chef-client -o 'recipe[mu-master::vault]'") end -end -# Figure out if our run list is dumb -MU.log "Verifying MU-MASTER's Chef run list", MU::NOTICE -MU::Groomer::Chef.loadChefLib -chef_node = ::Chef::Node.load("MU-MASTER") -run_list = ["role[mu-master]"] -run_list.concat($MU_CFG['master_runlist_extras']) if $MU_CFG['master_runlist_extras'].is_a?(Array) -set_runlist = false -run_list.each { |rl| - set_runlist = true if !chef_node.run_list?(rl) -} -if set_runlist - MU.log "Updating MU-MASTER run_list", MU::NOTICE, details: run_list - chef_node.run_list(run_list) - chef_node.save - $CHANGES << "chefrun" -else - MU.log "Chef run list looks correct", MU::NOTICE, details: run_list -end + if $MU_CFG['ldap']['type'] == "389 Directory Services" + begin + MU::Master::LDAP.listUsers + rescue Exception => e # XXX lazy exception handling is lazy + $CHANGES << "389ds" + end + if $INITIALIZE or $CHANGES.include?("389ds") + File.unlink("/root/389ds.tmp/389-directory-setup.inf") if File.exist?("/root/389ds.tmp/389-directory-setup.inf") + MU.log "Configuring 389 Directory Services", MU::NOTICE + set389DSCreds + system("chef-client -o 'recipe[mu-master::389ds]'") + exit 1 if $? != 0 + MU::Master::LDAP.initLocalLDAP + system("chef-client -o 'recipe[mu-master::sssd]'") + exit 1 if $? != 0 + end + end -# TODO here are some things we don't do yet but should -# accommodate running as a non-root user + # Figure out if our run list is dumb + MU.log "Verifying MU-MASTER's Chef run list", MU::NOTICE + MU::Groomer::Chef.loadChefLib + chef_node = ::Chef::Node.load("MU-MASTER") + run_list = ["role[mu-master]"] + run_list.concat($MU_CFG['master_runlist_extras']) if $MU_CFG['master_runlist_extras'].is_a?(Array) + set_runlist = false + run_list.each { |rl| + set_runlist = true if !chef_node.run_list?(rl) + } + if set_runlist + MU.log "Updating MU-MASTER run_list", MU::NOTICE, details: run_list + chef_node.run_list(run_list) + chef_node.save + $CHANGES << "chefrun" + else + MU.log "Chef run list looks correct", MU::NOTICE, details: run_list + end -if $INITIALIZE - MU::Config.emitSchemaAsRuby - MU.log "Generating YARD documentation in /var/www/html/docs (see http://#{$MU_CFG['public_address']}/docs/frames.html)" - File.umask(0022) - system("cd #{MU.myRoot} && umask 0022 && env -i PATH=#{ENV['PATH']} HOME=#{HOMEDIR} /usr/local/ruby-current/bin/yard doc modules -m markdown -o /var/www/html/docs && chcon -R -h -t httpd_sys_script_exec_t /var/www/html/") -end + # TODO here are some things we don't do yet but should + # accommodate running as a non-root user + if $INITIALIZE + MU::Config.emitSchemaAsRuby + MU.log "Generating YARD documentation in /var/www/html/docs (see http://#{$MU_CFG['public_address']}/docs/frames.html)" + File.umask(0022) + system("cd #{MU.myRoot} && umask 0022 && env -i PATH=#{ENV['PATH']} HOME=#{HOMEDIR} /usr/local/ruby-current/bin/yard doc modules -m markdown -o /var/www/html/docs && chcon -R -h -t httpd_sys_script_exec_t /var/www/html/") + end -MU.log "Running chef-client on MU-MASTER", MU::NOTICE -system("chef-client -o '#{run_list.join(",")}'") + MU.log "Running chef-client on MU-MASTER", MU::NOTICE + system("chef-client -o '#{run_list.join(",")}'") -if !File.exists?("#{MU_BASE}/var/users/mu/email") or !File.exists?("#{MU_BASE}/var/users/mu/realname") - MU.log "Finalizing the 'mu' Chef/LDAP account", MU::NOTICE - MU.setLogging(MU::Logger::SILENT) - MU::Master.manageUser( - "mu", - name: $MU_CFG['mu_admin_name'], - email: $MU_CFG['mu_admin_email'], - admin: true, - password: MU.generateWindowsPassword # we'll just overwrite this and do it with mu-user-manage below, which can do smart things with Scratchpad - ) - MU.setLogging(MU::Logger::NORMAL) - sleep 3 # avoid LDAP lag for mu-user-manage -end -output = %x{/opt/chef/bin/knife vault show scratchpad 2>&1} -if $?.exitstatus != 0 or output.match(/is not a chef-vault/) - MU::Groomer::Chef.saveSecret( - vault: "scratchpad", - item: "placeholder", - data: { "secret" => "DO NOT DELETE", "timestamp" => "9999999999" }, - permissions: "name:MU-MASTER" - ) -end + if !File.exist?("#{MU_BASE}/var/users/mu/email") or !File.exist?("#{MU_BASE}/var/users/mu/realname") + MU.log "Finalizing the 'mu' Chef/LDAP account", MU::NOTICE + MU.setLogging(MU::Logger::SILENT) + MU::Master.manageUser( + "mu", + name: $MU_CFG['mu_admin_name'], + email: $MU_CFG['mu_admin_email'], + admin: true, + password: MU.generateWindowsPassword # we'll just overwrite this and do it with mu-user-manage below, which can do smart things with Scratchpad + ) + MU.setLogging(MU::Logger::NORMAL) + sleep 3 # avoid LDAP lag for mu-user-manage + end -MU.log "Regenerating documentation in /var/www/html/docs" -%x{#{MU_BASE}/lib/bin/mu-gen-docs} + output = %x{/opt/chef/bin/knife vault show scratchpad 2>&1} + if $?.exitstatus != 0 or output.match(/is not a chef-vault/) + MU::Groomer::Chef.saveSecret( + vault: "scratchpad", + item: "placeholder", + data: { "secret" => "DO NOT DELETE", "timestamp" => "9999999999" }, + permissions: "name:MU-MASTER" + ) + end -if $INITIALIZE - MU.log "Setting initial password for admin user 'mu', for logging into Nagios and other built-in services.", MU::NOTICE - puts %x{#{MU_BASE}/lib/bin/mu-user-manage -g mu -n "#{$MU_CFG['mu_admin_name']}"} - MU.log "If Scratchpad web interface is not accessible, try the following:", MU::NOTICE - puts "#{MU_BASE}/lib/bin/mu-user-manage -g --no-scratchpad mu".bold -end + MU.log "Regenerating documentation in /var/www/html/docs" + %x{#{MU_BASE}/lib/bin/mu-gen-docs} -if !ENV['PATH'].match(/(^|:)#{Regexp.quote(MU_BASE)}\/bin(:|$)/) - MU.log "I added some entries to your $PATH, run this to import them:", MU::NOTICE - puts "source #{HOMEDIR}/.bashrc".bold + if $INITIALIZE + MU.log "Setting initial password for admin user 'mu', for logging into Nagios and other built-in services.", MU::NOTICE + puts %x{#{MU_BASE}/lib/bin/mu-user-manage -g mu -n "#{$MU_CFG['mu_admin_name']}"} + MU.log "If Scratchpad web interface is not accessible, try the following:", MU::NOTICE + puts "#{MU_BASE}/lib/bin/mu-user-manage -g --no-scratchpad mu".bold + end + + if !ENV['PATH'].match(/(^|:)#{Regexp.quote(MU_BASE)}\/bin(:|$)/) + MU.log "I added some entries to your $PATH, run this to import them:", MU::NOTICE + puts "source #{HOMEDIR}/.bashrc".bold + end end diff --git a/bin/mu-deploy b/bin/mu-deploy index 8bf52a4ca..28cabb2d6 100755 --- a/bin/mu-deploy +++ b/bin/mu-deploy @@ -42,6 +42,8 @@ Usage: opt :cloudformation_output, "When emitting a CloudFormation template, put the final product in this location instead of in /tmp. Takes a local file path or an s3:// URI. S3 uploads will be given AUTHENTICATED-READ permissions.", :require => false, :type => :string opt :verbose, "Display debugging output.", :require => false, :default => false, :type => :boolean opt :quiet, "Display minimal output.", :require => false, :default => false, :type => :boolean + opt :color, "Display log output in human-friendly colors.", :require => false, :default => true, :type => :boolean + opt :credentials, "Set the default credential set to use for resources which do not specify a default", :require => false, :type => :string end verbosity = MU::Logger::NORMAL verbosity = MU::Logger::LOUD if $opts[:verbose] @@ -58,7 +60,7 @@ if $opts[:liveupdate] and !$opts[:update] end MU.setVar("curRegion", $opts[:region]) if $opts[:region] -MU.setLogging(verbosity, $opts[:web]) +MU.setLogging(verbosity, $opts[:web], STDOUT, $opts[:color]) # Parse any paramater options into something useable. params = Hash.new @@ -87,11 +89,11 @@ end MU.log "Loading #{config}", html: $opts[:web], details: $opts -conf_engine = MU::Config.new(config, $opts[:skipinitialupdates], params: params, updating: $opts[:update]) +conf_engine = MU::Config.new(config, $opts[:skipinitialupdates], params: params, updating: $opts[:update], default_credentials: $opts[:credentials]) stack_conf = conf_engine.config if $opts[:dryrun] or $opts[:verbose] - puts stack_conf.to_yaml + puts MU::Config.stripConfig(stack_conf).to_yaml conf_engine.visualizeDependencies end @@ -112,6 +114,7 @@ if $opts[:dryrun] cost_dummy_deploy = MU::Deploy.new( $opts[:environment], verbosity: MU::Logger::SILENT, + color: $opts[:color], force_cloudformation: true, cloudformation_path: "/dev/null", nocleanup: false, @@ -135,6 +138,7 @@ if $opts[:update] deployer = MU::Deploy.new( deploy.environment, verbosity: verbosity, + color: $opts[:color], webify_logs: $opts[:web], nocleanup: true, # don't accidentally blow up an existing deploy stack_conf: stack_conf, @@ -156,6 +160,7 @@ end deployer = MU::Deploy.new( $opts[:environment], verbosity: verbosity, + color: $opts[:color], webify_logs: $opts[:web], nocleanup: $opts[:nocleanup], cloudformation_path: cfm_path, diff --git a/bin/mu-gcp-setup b/bin/mu-gcp-setup index 2797d2107..cf011e46d 100755 --- a/bin/mu-gcp-setup +++ b/bin/mu-gcp-setup @@ -31,6 +31,7 @@ require 'erb' require 'optimist' require 'json-schema' require 'mu' +require 'mu/master/ssl' Dir.chdir(MU.installDir) $opts = Optimist::options do @@ -64,7 +65,7 @@ my_instance_id = MU::Cloud::AWS.getAWSMetaData("instance-id") if MU::Cloud::Google.hosted? instance = MU.myCloudDescriptor - admin_sg_name = "mu-master-"+MU.myInstanceId+"-ingress-allow" + admin_sg_name = MU.myInstanceId+"-"+MU.myVPC+"-ingress-allow" if !instance.tags.items or !instance.tags.items.include?(admin_sg_name) newitems = instance.tags.items ? instance.tags.items.dup : [] newitems << admin_sg_name @@ -87,9 +88,10 @@ end # Create a security group, or manipulate an existing one, so that we have all # of the appropriate network holes. if $opts[:sg] - open_ports = [80, 443, 2260, 7443, 8443, 9443, 8200] + open_ports = [80, 443, MU.mommaCatPort, 7443, 8443, 9443, 8200] found = MU::MommaCat.findStray("Google", "firewall_rule", dummy_ok: true, cloud_id: admin_sg_name) + found.reject! { |v| v.cloud_desc.network != MU.myVPC } admin_sg = found.first if !found.nil? and found.size > 0 rules = [] @@ -108,21 +110,27 @@ if $opts[:sg] } cfg = { "name" => admin_sg_name, + "scrub_mu_isms" => true, "cloud" => "Google", "rules" => rules, "project" => MU::Cloud::Google.myProject, "target_tags" => [admin_sg_name], "vpc" => { - "vpc_id" => instance.network_interfaces.first.network + "id" => MU.myVPC } } if !admin_sg - admin_sg = MU::Cloud::FirewallRule.new(kitten_cfg: cfg, mu_name: "mu-master-"+MU.myInstanceId) - admin_sg.create - admin_sg.groom + admin_sg = MU::Cloud::FirewallRule.new(kitten_cfg: cfg, mu_name: admin_sg_name) + begin + admin_sg.create + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/alreadyExists: /) + ensure + admin_sg.groom + end else -# TODO Make sure the rules and tags are up to date + admin_sg.groom end end @@ -208,7 +216,7 @@ end if $opts[:uploadlogs] today = Time.new.strftime("%Y%m%d").to_s ["master.log", "nodes.log"].each { |log| - if File.exists?("/Mu_Logs/#{log}-#{today}") + if File.exist?("/Mu_Logs/#{log}-#{today}") MU.log "Uploading /Mu_Logs/#{log}-#{today} to bucket #{$bucketname}" MU::Cloud::AWS.s3.put_object( bucket: $bucketname, diff --git a/bin/mu-gen-docs b/bin/mu-gen-docs index 8c47f35c3..d0f9c399b 100755 --- a/bin/mu-gen-docs +++ b/bin/mu-gen-docs @@ -20,10 +20,9 @@ end require 'rubygems' require 'bundler/setup' -require 'json' require 'erb' -require 'optimist' -require 'json-schema' +require 'tempfile' +require 'fileutils' require File.realpath(File.expand_path(File.dirname(__FILE__)+"/mu-load-config.rb")) require 'mu' @@ -43,19 +42,110 @@ rescue yard = File.dirname(Gem.ruby)+'/yard' end +docdir = Process.uid == 0 ? "/var/www/html/docs" : MU.dataDir+"/docs" +if !Dir.exist?(docdir) + FileUtils.mkdir_p(docdir, mode: 0755) +end - +MU::Config.emitConfigAsRuby MU::Config.emitSchemaAsRuby -MU.log "Generating YARD documentation in /var/www/html/docs (see http://#{$MU_CFG['public_address']}/docs/frames.html)" +if Process.uid == 0 + MU.log "Generating YARD documentation in #{docdir} (see http://#{$MU_CFG['public_address']}/docs/frames.html)" +else + MU.log "Generating YARD documentation in #{docdir}" +end File.umask 0022 Dir.chdir(MU.myRoot) do - #exec "env -i PATH=#{ENV['PATH']} HOME=#{ENV['HOME']} #{yard} doc modules -m markdown -o /var/www/html/docs" THIS DOESNT WORK IN CICD + imgs = %w{alpha.png beta.png release.png} + FileUtils.cp(imgs.map { |f| "extras/"+f }, docdir) + FileUtils.chmod(0644, imgs.map { |f| docdir+"/"+f }) + + readme = File.read("README.md") + readme += < 0, + b => 0 + } + MU::Cloud.resource_types.each_pair { |type, cfg| + impl_counts[type] ||= 0 + [a, b].each { |cloud| + begin + myclass = Object.const_get("MU").const_get("Cloud").const_get(cloud).const_get(type) + case myclass.quality + when MU::Cloud::RELEASE + cloud_is_useful[cloud] = true + counts[cloud] += 4 + impl_counts[type] += 4 + when MU::Cloud::BETA + cloud_is_useful[cloud] = true + counts[cloud] += 2 + impl_counts[type] += 2 + when MU::Cloud::ALPHA + counts[cloud] += 1 + impl_counts[type] += 1 + end + rescue MU::Cloud::MuCloudResourceNotImplemented + end + } + } + counts[b] <=> counts[a] + } - system(%Q{chcon -R -h -t httpd_sys_script_exec_t /var/www/html/}) + cloudlist.reject! { |c| !cloud_is_useful[c] } + readme += "\n\n" + cloudlist.each { |cloud| + readme += "" + } + readme += "\n" + + icon_style = 'height:2.2em;width:2.2em;padding:0px;' + MU::Cloud.resource_types.keys.sort { |a, b| impl_counts[b] <=> impl_counts[a] }.each { |type| + readme += "" + cloudlist.each { |cloud| + readme += "" + } + readme += "\n" + } + readme += "
"+cloud+"
{MU::Config::BasketofKittens::#{MU::Cloud.resource_types[type][:cfg_plural]} #{type.to_s}}
" + begin + myclass = Object.const_get("MU").const_get("Cloud").const_get(cloud).const_get(type) + case myclass.quality + when MU::Cloud::RELEASE + readme += "[Release Quality]" + when MU::Cloud::BETA + readme += "[Beta Quality]" + when MU::Cloud::ALPHA + readme += "[Alpha Quality]" + else + readme += "?" + end + rescue MU::Cloud::MuCloudResourceNotImplemented + readme += "-" + end + readme += "
\n\n" + + idx = Tempfile.new('mu-gen-docs-index', MU.myRoot) + idx.write(readme) + idx.rewind + idx.close + idx_short = idx.path.gsub(/.*?\/([^\/]+)$/, '\1') + + system(%Q{#{yard} doc modules --readme #{idx_short} --markup markdown --output-dir #{docdir}}) + # --exclude seems to just... not work + FileUtils.rm(docdir+"/file."+idx_short+".html") + + if Process.uid == 0 + system(%Q{chcon -R -h -t httpd_sys_script_exec_t /var/www/html/}) + end system(%Q{#{yard} stats --list-undoc modules}) end diff --git a/bin/mu-load-config.rb b/bin/mu-load-config.rb index df0702089..551487634 100755 --- a/bin/mu-load-config.rb +++ b/bin/mu-load-config.rb @@ -64,6 +64,7 @@ def validateClouds(cfg) # into the Ruby $LOAD_PATH. # @return [Hash] def loadMuConfig(default_cfg_overrides = nil) + # Start with sane defaults default_cfg = { "installdir" => "/opt/mu", @@ -109,9 +110,25 @@ def loadMuConfig(default_cfg_overrides = nil) "dcs" => ["127.0.0.1"] } } + + in_gem = (Gem.paths and Gem.paths.home and File.dirname(__FILE__).match(/^#{Gem.paths.home}/)) + + if in_gem + default_cfg.delete("ldap") + default_cfg.delete("ssl") + default_cfg.delete("scratchpad") + default_cfg.delete("libdir") + default_cfg.delete("installdir") + else + if File.exist?("/opt/mu/etc/mu.yaml") + default_cfg.merge!(YAML.load(File.read("/opt/mu/etc/mu.yaml"))) + default_cfg["config_files"] = ["/opt/mu/etc/mu.yaml"] + end + end + default_cfg.merge!(default_cfg_overrides) if default_cfg_overrides - if !File.exists?(cfgPath) and Process.uid == 0 + if !File.exist?(cfgPath) and Process.uid == 0 puts "**** Master config #{cfgPath} does not exist, initializing *****" File.open(cfgPath, File::CREAT|File::TRUNC|File::RDWR, 0644){ |f| f.puts default_cfg.to_yaml @@ -119,7 +136,7 @@ def loadMuConfig(default_cfg_overrides = nil) end global_cfg = { "config_files" => [] } - if File.exists?(cfgPath) + if File.exist?(cfgPath) global_cfg = YAML.load(File.read(cfgPath)) global_cfg["config_files"] = [cfgPath] end @@ -127,33 +144,39 @@ def loadMuConfig(default_cfg_overrides = nil) home = Etc.getpwuid(Process.uid).dir username = Etc.getpwuid(Process.uid).name if File.readable?("#{home}/.mu.yaml") and cfgPath != "#{home}/.mu.yaml" - global_cfg.merge!(YAML.load(File.read("#{home}/.mu.yaml"))) - global_cfg["config_files"] << "#{home}/.mu.yaml" + localfile = YAML.load(File.read("#{home}/.mu.yaml")) + if localfile + global_cfg.merge!(localfile) + global_cfg["config_files"] << "#{home}/.mu.yaml" + end end if !global_cfg.has_key?("installdir") if ENV['MU_INSTALLDIR'] global_cfg["installdir"] = ENV['MU_INSTALLDIR'] - elsif Gem.paths and Gem.paths.home and !Dir.exists?("/opt/mu/lib") - global_cfg["installdir"] = File.realpath(File.expand_path(File.dirname(Gem.paths.home))+"/../../../") - else + elsif !in_gem global_cfg["installdir"] = "/opt/mu" end end if !global_cfg.has_key?("libdir") if ENV['MU_INSTALLDIR'] global_cfg["libdir"] = ENV['MU_INSTALLDIR']+"/lib" - else + elsif !in_gem global_cfg["libdir"] = File.realpath(File.expand_path(File.dirname(__FILE__))+"/..") end end if !global_cfg.has_key?("datadir") if username != "root" - global_cfg["datadir"] = home+"/.mu" + global_cfg["datadir"] = home+"/.mu/var" elsif global_cfg.has_key?("installdir") global_cfg["datadir"] = global_cfg["installdir"]+"/var" else global_cfg["datadir"] = "/opt/mu/var" end + default_cfg["ssl"] = { + "cert" => global_cfg["datadir"]+"/ssl/mommacat.crt", + "key" => global_cfg["datadir"]+"/ssl/mommacat.key", + "chain" => global_cfg["datadir"]+"/ssl/Mu_CA.pem" + } end exit 1 if !validateClouds(global_cfg) @@ -164,12 +187,29 @@ def loadMuConfig(default_cfg_overrides = nil) # Shorthand for locating the path to mu.yaml def cfgPath + in_gem = false + gemwhich = %x{gem which mu 2>&1}.chomp + gemwhich = nil if $?.exitstatus != 0 + mypath = File.realpath(File.expand_path(File.dirname(__FILE__))) + if !mypath.match(/^\/opt\/mu/) + if Gem.paths and Gem.paths.home and + (mypath.match(/^#{Gem.paths.home}/) or gemwhich.match(/^#{Gem.paths.home}/)) + in_gem = true + elsif $?.exitstatus == 0 and gemwhich and !gemwhich.empty? + $LOAD_PATH.each { |path| + if path.match(/\/cloud-mu-[^\/]+\/modules/) or + path.match(/#{Regexp.quote(gemwhich)}/) + in_gem = true + end + } + end + end home = Etc.getpwuid(Process.uid).dir username = Etc.getpwuid(Process.uid).name - if Process.uid == 0 + if Process.uid == 0 and !in_gem if ENV.include?('MU_INSTALLDIR') ENV['MU_INSTALLDIR']+"/etc/mu.yaml" - elsif Dir.exists?("/opt/mu") + elsif Dir.exist?("/opt/mu") "/opt/mu/etc/mu.yaml" else "#{home}/.mu.yaml" @@ -180,7 +220,7 @@ def cfgPath end def cfgExists? - File.exists?(cfgPath) + File.exist?(cfgPath) end # Output an in-memory configuration hash to the standard config file location, diff --git a/bin/mu-momma-cat b/bin/mu-momma-cat new file mode 100755 index 000000000..ba015bc91 --- /dev/null +++ b/bin/mu-momma-cat @@ -0,0 +1,36 @@ +#!/usr/local/ruby-current/bin/ruby +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require File.expand_path(File.dirname(__FILE__))+"/mu-load-config.rb" +require 'rubygems' +require 'bundler/setup' +require 'mu' + +if ARGV.size > 1 or !["start", "stop", "restart"].include?(ARGV[0]) + MU.log "Expecting start, stop, or restart argument", MU::ERR + exit 1 +end + + +case ARGV[0] +when "start" + MU::MommaCat.start +when "stop" + MU::MommaCat.stop +when "restart" + MU::MommaCat.restart +when "status" + MU::MommaCat.status +end diff --git a/bin/mu-node-manage b/bin/mu-node-manage index 9e0f32871..9313ffba5 100755 --- a/bin/mu-node-manage +++ b/bin/mu-node-manage @@ -88,7 +88,7 @@ avail_deploys = MU::MommaCat.listDeploys do_deploys = [] do_nodes = [] ok = true -if $opts[:all] +if $opts[:all] or (ARGV.size == 0 and !$opts[:deploys]) do_deploys = avail_deploys else if $opts[:deploys] and !$opts[:all] @@ -102,16 +102,20 @@ else else do_nodes = ARGV do_deploys = [] + matched = 0 if do_nodes.size > 0 # Just load the deploys we need do_nodes.each { |node| if node.match(/^(.*?-[^\-]+?-\d{10}-[A-Z]{2})-.*/) + matched += 1 do_deploys << node.sub(/^(.*?-[^\-]+?-\d{10}-[A-Z]{2})-.*/, '\1') end } do_deploys.uniq! end - do_deploys = avail_deploys if do_deploys.size == 0 + if do_deploys.size == 0 and do_nodes.size > 0 and (matched > 0 or ARGV.size > 0) + do_deploys = avail_deploys + end end end @@ -178,36 +182,38 @@ def reGroom(deploys = MU::MommaCat.listDeploys, nodes = [], vaults_only: false) deploys.each { |muid| mommacat = MU::MommaCat.new(muid) next if mommacat.kittens.nil? or mommacat.kittens['servers'].nil? - mommacat.kittens['servers'].each_pair { |nodeclass, servers| - servers.each_pair { |mu_name, server| - next if nodes.size > 0 and !nodes.include?(mu_name) - count = count + 1 - child = Process.fork { - begin - type = "server" - type = "server_pool" if server.config.has_key?("basis") - if vaults_only - next if !server.config.has_key?("vault_access") - server.config["vault_access"].each { |v| - MU::Groomer::Chef.grantSecretAccess(mu_name, v['vault'], v['item']) - } - else - mommacat.groomNode(server.cloud_id, nodeclass, type, mu_name: mu_name) + mommacat.kittens['servers'].each_pair { |habitat, nodeclasses| + nodeclasses.each_pair { |nodeclass, servers| + servers.each_pair { |mu_name, server| + next if nodes.size > 0 and !nodes.include?(mu_name) + count = count + 1 + child = Process.fork { + begin + type = "server" + type = "server_pool" if server.config.has_key?("basis") + if vaults_only + next if !server.config.has_key?("vault_access") + server.config["vault_access"].each { |v| + MU::Groomer::Chef.grantSecretAccess(mu_name, v['vault'], v['item']) + } + else + mommacat.groomNode(server.cloud_id, nodeclass, type, mu_name: mu_name) + end + rescue Exception => e + MU.log e.inspect, MU::ERR, details: e.backtrace + exit 1 end - rescue Exception => e - MU.log e.inspect, MU::ERR, details: e.backtrace - exit 1 - end + } + $children[child] = mu_name } - $children[child] = mu_name - } - while $children.size >= $opts[:concurrent]-1 - child = Process.wait - if !$?.success? - badnodes << $children[child] + while $children.size >= $opts[:concurrent]-1 + child = Process.wait + if !$?.success? + badnodes << $children[child] + end + $children.delete(child) end - $children.delete(child) - end + } } } Process.waitall.each { |child| @@ -236,6 +242,10 @@ def runCommand(deploys = MU::MommaCat.listDeploys, nodes = [], cmd = nil, print_ done = false begin serverobj = mommacat.findLitterMate(type: "server", mu_name: nodename) + if !serverobj + MU.log "Failed to load server object for #{nodename}", MU::ERR + next + end # Generate the command if attemting a chef run if chefrun @@ -389,7 +399,9 @@ def updateAWSMetaData(deploys = MU::MommaCat.listDeploys, nodes = []) next if !matched end - MU::Cloud::AWS::Server.createIAMProfile(pool_name, base_profile: server['iam_role'], extra_policies: server['iam_policies']) +# MU::Cloud::AWS::Server.createIAMProfile(pool_name, base_profile: server['iam_role'], extra_policies: server['iam_policies']) + pool_obj = mommacat.findLitterMate(type: "server_pool", mu_name: pool_name) + pool_obj.groom resp = MU::Cloud::AWS.autoscale.describe_auto_scaling_groups( auto_scaling_group_names: [pool_name] @@ -413,6 +425,7 @@ def updateAWSMetaData(deploys = MU::MommaCat.listDeploys, nodes = []) "deploySSHKey" => mommacat.ssh_public_key, "muID" => muid, "muUser" => MU.chef_user, + "mommaCatPort" => MU.mommaCatPort, "publicIP" => MU.mu_public_ip, "resourceName" => svr_class, "windowsAdminName" => server['windows_admin_username'], @@ -535,20 +548,24 @@ def updateAWSMetaData(deploys = MU::MommaCat.listDeploys, nodes = []) end id = server['cloud_id'] id = server['instance_id'] if id.nil? - desc = MU::Cloud::AWS.ec2(server['region']).describe_instances(instance_ids: [id]).reservations.first.instances.first + desc = MU::Cloud::AWS.ec2(region: server['region']).describe_instances(instance_ids: [id]).reservations.first.instances.first server['conf']["platform"] = "linux" if !server['conf'].has_key?("platform") next if nodes.size > 0 and !nodes.include?(nodename) - rolename, cfm_role_name, cfm_prof_name, arn = MU::Cloud::AWS::Server.createIAMProfile(nodename, base_profile: server["conf"]['iam_role'], extra_policies: server["conf"]['iam_policies']) - MU::Cloud::AWS::Server.addStdPoliciesToIAMProfile(rolename) - mytype = "server" - mytype = "server_pool" if server['conf'].has_key?("basis") or server['conf']['#TYPENAME'] == "ServerPool" or server['conf']["#MU_CLASS"] == "MU::Cloud::AWS::ServerPool" - olduserdata = Base64.decode64(MU::Cloud::AWS.ec2(server['region']).describe_instance_attribute( - instance_id: id, - attribute: "userData" - ).user_data.value) + if server['conf'].has_key?("basis") or + server['conf']['#TYPENAME'] == "ServerPool" or + server['conf']["#MU_CLASS"] == "MU::Cloud::AWS::ServerPool" + mytype = "server_pool" + else + server_obj = mommacat.findLitterMate(type: "server", mu_name: nodename) + server_obj.groom + end + olduserdata = Base64.decode64(MU::Cloud::AWS.ec2(region: server['region']).describe_instance_attribute( + instance_id: id, + attribute: "userData" + ).user_data.value) userdata = MU::Cloud::AWS::Server.fetchUserdata( platform: server['conf']["platform"], diff --git a/bin/mu-self-update b/bin/mu-self-update index ddff9cf06..72e70272e 100755 --- a/bin/mu-self-update +++ b/bin/mu-self-update @@ -190,7 +190,7 @@ if [ "$chef_major" == "12" ];then elif [ "$DIST_VERSION" == "server" ];then # funny package name in RHEL6 DIST_VERSION="6" fi - rpm -Uvh https://packages.chef.io/files/stable/chef/14.11.21/el/${DIST_VERSION}/chef-14.11.21-1.el${DIST_VERSION}.x86_64.rpm + rpm -Uvh https://packages.chef.io/files/stable/chef/14.13.11/el/${DIST_VERSION}/chef-14.13.11-1.el${DIST_VERSION}.x86_64.rpm fi /opt/chef/bin/chef-apply $MU_LIBDIR/cookbooks/mu-master/recipes/init.rb @@ -198,13 +198,18 @@ fi /bin/rm -rf $MU_DATADIR/tmp/cookbook_changes.$$ /bin/rm -rf $MU_DATADIR/tmp/berks_changes.$$ +/bin/rm -rf /root/.berkshelf/ if [ "$rebuild_chef_artifacts" == "1" ];then - /bin/rm -rf /root/.berkshelf/cookbooks + cd $MU_LIBDIR && berks install && berks update $bindir/mu-upload-chef-artifacts -p -else - $bindir/mu-upload-chef-artifacts -r mu fi -$bindir/mu-configure -n + +# Make double sure our purely-mu cookbooks are uploaded and ready for platform +# repos to reference. +$bindir/mu-upload-chef-artifacts -r mu + +# Now a regular upload for platform repos. +$bindir/mu-upload-chef-artifacts for dir in $MU_LIBDIR /opt/chef/embedded /opt/opscode/embedded /usr/local/ruby-current/;do echo "${GREEN}Sanitizing permissions in ${BOLD}$dir${NORM}${GREEN}${NORM}" @@ -215,6 +220,8 @@ for dir in $MU_LIBDIR /opt/chef/embedded /opt/opscode/embedded /usr/local/ruby-c done chmod go+rx $MU_LIBDIR/bin/* +$bindir/mu-configure -n + set -e if [ "$branch" != "$lastbranch" -a "$discard" != "1" ];then diff --git a/bin/mu-ssh b/bin/mu-ssh index 8019f343e..1ac52560a 100755 --- a/bin/mu-ssh +++ b/bin/mu-ssh @@ -1,5 +1,5 @@ -#!/bin/sh -# Copyright:: Copyright (c) 2017 eGlobalTech, Inc., all rights reserved +#!/usr/local/ruby-current/bin/ruby +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved # # Licensed under the BSD-3 license (the "License"); # you may not use this file except in compliance with the License. @@ -13,11 +13,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -set -e +require File.realpath(File.expand_path(File.dirname(__FILE__)+"/mu-load-config.rb")) +require 'mu' -bindir="`dirname $0`" +argument = ARGV[0] -for host in $($bindir/mu-node-manage -l $@);do - echo $host - ssh $host -done +avail_deploys = MU::MommaCat.listAllNodes +check = avail_deploys.keys + +check.grep(*/#{argument}*/i).each do |n| + puts n + system("ssh #{n}") +end diff --git a/bin/mu-upload-chef-artifacts b/bin/mu-upload-chef-artifacts index cee5b3029..a31737c49 100755 --- a/bin/mu-upload-chef-artifacts +++ b/bin/mu-upload-chef-artifacts @@ -128,9 +128,13 @@ add_berkshelf_cookbooks() berksdir="$user_home/.berkshelf" berks="/usr/local/ruby-current/bin/ruby /usr/local/ruby-current/bin/berks" cd $repodir - is_synced="`$berks list -F json 2>&1 | grep 'lockfile is out of sync'`" - if [ "$MU_DEPRESOLVE" == "1" -o "$is_synced" != "" ];then + need_rebuild="`$berks list -F json 2>&1 | egrep '(lockfile is out of sync|is not installed)'`" + if ! $berks list -F json 2>&1 > /dev/null ;then + need_rebuild=1 + fi + + if [ "$MU_DEPRESOLVE" == "1" -o "$need_rebuild" != "" ];then # The cleansing fire for Berksfile.lock and ~/.berkshelf/cookbooks if [ "$MU_DEPRESOLVE" == "1" ];then echo "${GREEN}Resolving standard Mu cookbook dependencies in $repodir/Berksfile${NORM}" @@ -164,8 +168,8 @@ add_berkshelf_cookbooks() $berks install fi - is_synced="`$berks list -F json 2>&1 | grep 'lockfile is out of sync'`" - if [ "$is_synced" != "" ];then + need_sync="`$berks list -F json 2>&1 | grep 'lockfile is out of sync'`" + if [ "$need_sync" != "" ];then echo "${RED}$repodir/Berksfile.lock still out of sync after install, bailing${NORM}" $berks install -d $berks upload -d --no-freeze @@ -527,6 +531,9 @@ for repo in $REPOS;do oldsrc="`grep "^$artifact:$shortname:" $manifest`" set -e if [ "$artifact" == "cookbooks" -o "$artifact" == "site_cookbooks" ];then + if [ ! -d "$repo_name/$artifact/$shortname" ];then + continue + fi if [ $create_berksfile == "1" ];then echo "cookbook '$shortname', path: ENV['MU_DATADIR']+'/$repo_name/$artifact/$shortname'" >> "$artifact_source/Berksfile" fi diff --git a/bin/mu-user-manage b/bin/mu-user-manage index 11fa68453..07a9010d0 100755 --- a/bin/mu-user-manage +++ b/bin/mu-user-manage @@ -275,5 +275,8 @@ if $password MU.log "Generated password for #{$username}: #{$password}", MU::NOTICE end end +if File.exist?("/sbin/sss_cache") + %x{/sbin/sss_cache -E} +end MU::Master.printUsersToTerminal diff --git a/cloud-mu.gemspec b/cloud-mu.gemspec index dd001297d..4d36feaa6 100644 --- a/cloud-mu.gemspec +++ b/cloud-mu.gemspec @@ -17,22 +17,18 @@ end Gem::Specification.new do |s| s.name = 'cloud-mu' - s.version = '2.0.4' - s.date = '2019-08-28' + s.version = '3.0.0' + s.date = '2019-11-11' s.require_paths = ['modules'] s.required_ruby_version = '>= 2.4' s.summary = "The eGTLabs Mu toolkit for unified cloud deployments" s.description = <<-EOF -The eGTLabs Mu toolkit for unified cloud deployments. This gem contains a minimal version of Mu with cloud provider APIs, and will generate a sample configuration the first time it is invoked. - -It will attempt to autodetect when it's being run in a virtual machine on a known cloud provider and activate the appropriate API with machine-based credentials. Installing this gem on an Amazon Web Service instance, for example, should automatically enable the MU::Cloud::AWS layer and attempt to use the machine's IAM Profile to communicate with the AWS API. - - -require 'cloud-mu' +The eGTLabs Mu toolkit for unified cloud deployments. This gem contains the Mu deployment interface to cloud provider APIs. It will generate a sample configuration the first time it is invoked. +Mu will attempt to autodetect when it's being run in a virtual machine on a known cloud provider and activate the appropriate API with machine-based credentials. Installing this gem on an Amazon Web Service instance, for example, should automatically enable the MU::Cloud::AWS layer and attempt to use the machine's IAM Profile to communicate with the AWS API. EOF - s.authors = ["John Stange", "Robert Patt-Corner", "Ryan Bolyard", "Clara Bridges", "Zach Rowe"] + s.authors = ["John Stange", "Robert Patt-Corner", "Ryan Bolyard", "Zach Rowe"] s.email = 'eGTLabs@eglobaltech.com' s.files = build_file_list(whereami) s.executables = Dir.entries(whereami+"/bin").reject { |f| File.directory?(f) } @@ -58,8 +54,9 @@ EOF s.add_runtime_dependency 'net-ssh', "~> 4.2" s.add_runtime_dependency 'net-ssh-multi', '~> 1.2', '>= 1.2.1' s.add_runtime_dependency 'googleauth', "~> 0.6" - s.add_runtime_dependency 'google-api-client', "~> 0.28.4" + s.add_runtime_dependency 'google-api-client', "~> 0.30.8" s.add_runtime_dependency 'rubocop', '~> 0.58' s.add_runtime_dependency 'addressable', '~> 2.5' s.add_runtime_dependency 'slack-notifier', "~> 2.3" + s.add_runtime_dependency 'azure_sdk', "~> 0.37" end diff --git a/cookbooks/firewall/libraries/helpers_iptables.rb b/cookbooks/firewall/libraries/helpers_iptables.rb index aaa10c79f..5d5fdb7ba 100644 --- a/cookbooks/firewall/libraries/helpers_iptables.rb +++ b/cookbooks/firewall/libraries/helpers_iptables.rb @@ -55,8 +55,8 @@ def iptables_packages(new_resource) %w(iptables) end - # centos 7 requires extra service - if !debian?(node) && node['platform_family'] != "amazon" && node['platform_version'].to_i >= 7 + # centos 7 and AzL2 requires extra service + if !debian?(node) && (node['platform_version'].to_i >= 7 || node['platform_version'].to_i == 2) packages << %w(iptables-services) end diff --git a/cookbooks/mu-firewall/attributes/default.rb b/cookbooks/mu-firewall/attributes/default.rb index 80b1f7980..5ccbcecc6 100644 --- a/cookbooks/mu-firewall/attributes/default.rb +++ b/cookbooks/mu-firewall/attributes/default.rb @@ -1,3 +1,5 @@ default['firewall']['allow_ssh'] = true default['firewall']['firewalld']['permanent'] = true default['firewall']['ipv6_enabled'] = false +default['firewall']['allow_loopback'] = true +force_default['firewall']['allow_established'] = true \ No newline at end of file diff --git a/cookbooks/mu-firewall/metadata.rb b/cookbooks/mu-firewall/metadata.rb index 7891e9dc2..c8b1b3d3c 100644 --- a/cookbooks/mu-firewall/metadata.rb +++ b/cookbooks/mu-firewall/metadata.rb @@ -7,7 +7,7 @@ source_url 'https://github.com/cloudamatic/mu' issues_url 'https://github.com/cloudamatic/mu/issues' chef_version '>= 12.1' if respond_to?(:chef_version) -version '0.1.0' +version '0.1.2' %w( amazon centos redhat windows ).each do |os| supports os diff --git a/cookbooks/mu-glusterfs/templates/default/mu-gluster-client.erb b/cookbooks/mu-glusterfs/templates/default/mu-gluster-client.erb old mode 100755 new mode 100644 diff --git a/cookbooks/mu-jenkins/Berksfile b/cookbooks/mu-jenkins/Berksfile deleted file mode 100644 index f5c68badc..000000000 --- a/cookbooks/mu-jenkins/Berksfile +++ /dev/null @@ -1,14 +0,0 @@ -source 'https://supermarket.chef.io' -source chef_repo: ".." - -metadata - -# Mu Cookbooks -cookbook 'mu-master' -cookbook 'mu-utility' -cookbook 'mu-tools' - -# Supermarket Cookbooks -cookbook 'java', '~> 2.2.0' -cookbook 'jenkins', '~> 6.2.0' -cookbook 'chef-vault', '~> 3.1.1' \ No newline at end of file diff --git a/cookbooks/mu-jenkins/CHANGELOG.md b/cookbooks/mu-jenkins/CHANGELOG.md deleted file mode 100644 index 9590c6f41..000000000 --- a/cookbooks/mu-jenkins/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ -mu-jenkins CHANGELOG -==================== - -This file is used to list changes made in each version of the mu-jenkins cookbook. - -0.1.0 ------ -- [your_name] - Initial release of mu-jenkins - -- - - -Check the [Markdown Syntax Guide](http://daringfireball.net/projects/markdown/syntax) for help with Markdown. - -The [Github Flavored Markdown page](http://github.github.com/github-flavored-markdown/) describes the differences between markdown on github and standard markdown. diff --git a/cookbooks/mu-jenkins/LICENSE b/cookbooks/mu-jenkins/LICENSE deleted file mode 100644 index cd1bdd111..000000000 --- a/cookbooks/mu-jenkins/LICENSE +++ /dev/null @@ -1,37 +0,0 @@ -Through accessing, reading, or utilizing this software in any manner whatsoever -or through any means whatsoever, whether the access, reading or use is either -solely looking at this software or this software has been integrated into any -derivative work, the party accessing, reading, or utilizing the software -directly or indirectly agrees to abide by the following license. - -The eGlobalTech Cloud Automation Platform is the Copyright (c) 2014 of Global -Tech Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, -this list of conditions and the following disclaimer in the documentation -and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its contributors -may be used to endorse or promote products derived from this software without -specific prior written permission. - -Global Tech, Inc. is the co-owner of any derivative works created with this -software. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - diff --git a/cookbooks/mu-jenkins/README.md b/cookbooks/mu-jenkins/README.md deleted file mode 100644 index a89bc4803..000000000 --- a/cookbooks/mu-jenkins/README.md +++ /dev/null @@ -1,105 +0,0 @@ -mu-jenkins Cookbook -=================== -This cookbook creates a working Jenkins installation. It can be deployed on a standalone node (see demo/jenkins.yaml) or as a Jenkins server on the mu-master itself. - -Requirements ------------- -This is a wrapper cookbook that is meant to be run after a Jenkins install using the Jenkins community cookbook. The recipe uses some groovy scripts to manage jenkins authentication from chef itself, and create an additional administrave Jenkins user for interactive work. - -A jenkins vault must be present before invoking. Two items are required -- A users item containing passwords for each user enumerated in the default.jenkins_users attribute (see below). The mu-user password is required, as we need at least one interactive Jenkins user -- An admin item containing a public and private keypair that will be used by chef to authenticate to Jenkins after disabling anonymous authentication, and a username for this user - -A third optional ssh item is used to store a keypair used by Jenkins to SSH to other nodes, to allow Jenkins to run code locally as part of a Jenkins job. - -Create the vault items along these lines: - -admin: -``` -#!/usr/local/ruby-current/bin/ruby -require "openssl" -require 'net/ssh' -key = OpenSSL::PKey::RSA.new 2048 -public_key = "#{key.public_key.ssh_type} #{[key.public_key.to_blob].pack('m0')}" -vault_opts="--mode client -u mu -F json" -vault_cmd = "knife vault create jenkins admin '{ \"public_key\":\"#{public_key}\", \"private_key\":\"#{key.to_pem.chomp!.gsub(/\n/, "\\n")}\", \"username\": \"master_user\" }' #{vault_opts} --search name:MU-MASTER" -exec vault_cmd -``` - -users: -```knife vault create jenkins users '{"mu_user_password":"feefiefoefum"}' --mode client -F json -u mu --search name:MU-MASTER``` - - -#### packages -- `java` - jenkins needs Java to run -- `jenkins` - mu-jenkins needs jenkins to actually be installed - -Attributes ----------- -Some basic attributes on the java install and node address, plus Jenkins specifics: - -#### mu-jenkins::default - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
KeyTypeDescriptionDefault
default.jenkins_usersHashJenkins users to create with their properties (excepting password) and a single vault to retrieve creds from:user_name => "mu_user", :fullname => "Mu-Demo-User", :email => "mu-developers@googlegroups.com", :vault => "jenkins", :vault_item => "users"}
default.jenkins_ssh_urlsArrayIP addresses / DNS names of nodes Jenkins will SSH into[node[:ipaddress]]
default.jenkins_pluginsWhitespace stringplugins to install%w{github ssh deploy}
default.jenkins_ssh_vaultHashPreexisting vault containing a public private keypair that will be used to SSH to other nodes:vault => "jenkins", :item => "ssh"
default.jenkins_admin_vaultHashPreexisting vault containing a public private keypair used by Chef to authenticate to Jenkins. This also include the username of the Jenkins user:vault => "jenkins", :item => "admin"
- -Usage ------ -#### mu-jenkins::default -This cookbook can run in a standalone mode which creates a basic Jenkins install on a target node, or a mu-master mode which creates a Jenkins server on a mu master. - -In either case the runlist will look like: -``` run_list: - - recipe[java] - - recipe[jenkins::master] - - recipe[mu-jenkins] -``` - -In the mu-master mode the cookbook is invoked with the role[mu-master-jenkins], which adds some attributes to trigger the jenkins-apache recipe, which places Jenkins behind a mu-master apache reverse proxy: - - chef-client -l info -o recipe[java],recipe[jenkins::master],recipe[mu-jenkins] - - -Contributing ------------- -Usual Cloudamatic process via pull request - - -License and Authors -------------------- -Authors: Ami Rahav, Robert Patt-Corner diff --git a/cookbooks/mu-jenkins/attributes/default.rb b/cookbooks/mu-jenkins/attributes/default.rb deleted file mode 100644 index 8eb408c42..000000000 --- a/cookbooks/mu-jenkins/attributes/default.rb +++ /dev/null @@ -1,42 +0,0 @@ - -default['jenkins_users'] = [ -# {:user_name => "mu_user", :fullname => "Mu-Demo-User", :email => ENV['MU_ADMIN_EMAIL'], :vault => "jenkins", :vault_item => "users"} -] - -default['jenkins_ssh_urls'] = [node['ipaddress']] -default['jenkins_plugins'] = %w{ - token-macro git github deploy ldap scm-api git-client active-directory - ansicolor matrix-auth matrix-project workflow-scm-step junit workflow-api - workflow-step-api ssh credentials ssh-credentials plain-credentials mailer - display-url-api structs script-security jackson2-api -} - -default['jenkins_ports_direct'] = %w{8080 443} -default['jenkins']['master']['jenkins_args'] = "" if default['jenkins']['master']['jenkins_args'].nil? -jenkins_args = "" if node['jenkins']['master']['jenkins_args'].nil? -override['jenkins']['master']['jenkins_args'] = "#{jenkins_args} --prefix=/jenkins" -default['jenkins']['master']['jvm_options'] = '-Xmx1024m -Djenkins.install.runSetupWizard=false' - - -alpn_version = '8.1.11.v20170118' -default['jenkins']['alpn']['version'] = alpn_version -default['jenkins']['alpn']['download_link'] = "http://central.maven.org/maven2/org/mortbay/jetty/alpn/alpn-boot/#{alpn_version}/alpn-boot-#{alpn_version}.jar" - -# This isn't really true, but the Java libraries lose their minds over -# self-signed SSL certs like the one you'll usually find on -# https://#{$MU_CFG['public_address']}/jenkins (the real URL) -default['jenkins']['master']['endpoint'] = "http://localhost:8080/jenkins" -default['jenkins_ssh_vault'] = { - :vault => "jenkins", :item => "ssh" -} - -default['jenkins_admin_vault'] = { - :vault => "jenkins", :item => "admin" -} - -override['java']['jdk_version'] = 8 -override['java']['flavor'] = 'oracle' -override['java']['jdk']['8']['x86_64']['url'] = 'http://download.oracle.com/otn-pub/java/jdk/8u131-b11/d54c1d3a095b4ff2b6607d096fa80163/jdk-8u131-linux-x64.tar.gz' -override['java']['jdk']['8']['x86_64']['checksum'] = '75b2cb2249710d822a60f83e28860053' -override["java"]["oracle"]["accept_oracle_download_terms"] = true -override['java']['oracle']['jce']['enabled'] = true diff --git a/cookbooks/mu-jenkins/files/default/cleanup_deploy_config.xml b/cookbooks/mu-jenkins/files/default/cleanup_deploy_config.xml deleted file mode 100644 index d0618921c..000000000 --- a/cookbooks/mu-jenkins/files/default/cleanup_deploy_config.xml +++ /dev/null @@ -1,73 +0,0 @@ - - - - Clean up an extant Cloudamatic Deploy - false - - - true - hudson.model.Item.Cancel:master_user - hudson.model.Item.Cancel:mu_user - hudson.model.Item.Delete:master_user - hudson.model.Item.Delete:mu_user - hudson.model.Item.Read:master_user - hudson.model.Item.Read:mu_user - hudson.model.Item.Workspace:master_user - hudson.model.Item.Workspace:mu_user - hudson.model.Item.Build:master_user - hudson.model.Item.Build:mu_user - hudson.model.Item.Move:master_user - hudson.model.Item.Move:mu_user - hudson.model.Item.Configure:master_user - hudson.model.Item.Configure:mu_user - hudson.model.Item.Discover:master_user - hudson.model.Item.Discover:mu_user - - - - - Target_Deploy - - <__uuid>8a5d5b3c-c975-44f8-a1e2-7d3c4f3306f8 - <__remote>false - <__script>def proc = "ls -1a /home/jenkins/.mu/var/deployments/".execute() - def b = new StringBuffer() - proc.consumeProcessErrorStream(b) - def list = proc.text.readLines() - - <__localBaseDirectory serialization="custom"> - - - /home/jenkins/dynamic_parameter/classpath - - true - - - <__remoteBaseDirectory>dynamic_parameter_classpath - <__classPath> - false - - - - - - true - false - false - false - - false - - - source ~/.murc - mu-cleanup $Target_Deploy - - - - - - - xterm - - - \ No newline at end of file diff --git a/cookbooks/mu-jenkins/files/default/deploy_config.xml b/cookbooks/mu-jenkins/files/default/deploy_config.xml deleted file mode 100644 index 945e278ae..000000000 --- a/cookbooks/mu-jenkins/files/default/deploy_config.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - false - - - true - hudson.model.Item.Cancel:master_user - hudson.model.Item.Cancel:mu_user - hudson.model.Item.Delete:master_user - hudson.model.Item.Delete:mu_user - hudson.model.Item.Read:master_user - hudson.model.Item.Read:mu_user - hudson.model.Item.Workspace:master_user - hudson.model.Item.Workspace:mu_user - hudson.model.Item.Build:master_user - hudson.model.Item.Build:mu_user - hudson.model.Item.Move:master_user - hudson.model.Item.Move:mu_user - hudson.model.Item.Configure:master_user - hudson.model.Item.Configure:mu_user - hudson.model.Item.Discover:master_user - hudson.model.Item.Discover:mu_user - - - - true - false - false - false - - false - - - /opt/mu/bin/mu-deploy /opt/mu/lib/demo/simple-server.yaml - - - - - - xterm - - - \ No newline at end of file diff --git a/cookbooks/mu-jenkins/metadata.rb b/cookbooks/mu-jenkins/metadata.rb deleted file mode 100644 index d5fed61c3..000000000 --- a/cookbooks/mu-jenkins/metadata.rb +++ /dev/null @@ -1,21 +0,0 @@ -name 'mu-jenkins' -maintainer 'eGlobalTech, Inc' -maintainer_email 'mu-developers@googlegroups.com' -license 'BSD-3-Clause' -description 'Installs/Configures mu-jenkins' -long_description IO.read(File.join(File.dirname(__FILE__), 'README.md')) -source_url 'https://github.com/cloudamatic/mu' -issues_url 'https://github.com/cloudamatic/mu/issues' -chef_version '>= 12.1' if respond_to?(:chef_version) -version '0.6.0' - -%w( amazon centos redhat windows ).each do |os| - supports os -end - -depends 'java', '~> 2.2.0' -depends 'jenkins', '~> 6.2.0' -depends 'chef-vault', '~> 3.1.1' -depends 'mu-master' -depends 'mu-utility' -depends 'mu-tools' diff --git a/cookbooks/mu-jenkins/recipes/default.rb b/cookbooks/mu-jenkins/recipes/default.rb deleted file mode 100644 index 1b8a9ef83..000000000 --- a/cookbooks/mu-jenkins/recipes/default.rb +++ /dev/null @@ -1,195 +0,0 @@ -# Cookbook Name:: mu-jenkins -# Recipe:: default -# -# Copyright 2015, eGlobalTech, Inc -# -# All rights reserved - Do Not Redistribute -# - -include_recipe 'mu-tools::disable-requiretty' -include_recipe 'chef-vault' - -directory "/opt/java_jce" do - mode 0755 -end - -admin_vault = chef_vault_item(node['jenkins_admin_vault']['vault'], node['jenkins_admin_vault']['item']) - -directory "Mu Jenkins home #{node['jenkins']['master']['home']}" do - path node['jenkins']['master']['home'] - owner "jenkins" - recursive true - notifies :restart, 'service[jenkins]', :immediately -end - -package %w{git bzip2} - -#remote_file "#{node['jenkins']['master']['home']}/plugins/mailer.jpi" do -# source "http://updates.jenkins-ci.org/latest/mailer.hpi" -# owner "jenkins" -#end - -ruby_block 'wait for jenkins' do - block do - sleep 30 - end - action :nothing -end - - -# If security was enabled in a previous chef run then set the private key in the run_state -# now as required by the Jenkins cookbook -if node['application_attributes']['jenkins_auth_set'] -ruby_block 'set jenkins private key' do - block do - Chef::Log.info("Setting the previously enabled jenkins private key") - node.run_state[:jenkins_private_key] = admin_vault['private_key'].strip - end -end -end - -restart_jenkins = false - -directory "mu-jenkins fix #{Chef::Config[:file_cache_path]} perms" do - path Chef::Config[:file_cache_path] - mode 0755 -end -cacheparent = File.expand_path("..", Chef::Config[:file_cache_path]) -directory "mu-jenkins fix #{cacheparent} perms" do - path cacheparent - mode 0755 -end - - -# Download ALPN Jar file and fix to JENKINS_JAVA_OPTIONS -# open_jdk_version = `java -version 2>&1` -open_jdk_version = shell_out('java -version 2>&1').stdout.str -if open_jdk_version.include?("openjdk version \"1.8") and node['platform_family'] == 'rhel' - - remote_file 'download_anlp_jar' do - source node['jenkins']['alpn']['download_link'] - path "/home/jenkins/alpn-boot-#{node['jenkins']['alpn']['version']}.jar" - owner "jenkins" - notifies :restart, "service[jenkins]",:delayed - end - - service "jenkins" do - action :nothing - end - -end - - - - -node['jenkins_plugins'].each { |plugin| -# if !::File.exist?("#{node['jenkins']['master']['home']}/plugins/#{plugin}.jpi") -# restart_jenkins = true -# end -# XXX this runs as the 'jenkins' user, yet download the files as 0600/root - jenkins_plugin plugin - # do - # notifies :restart, 'service[jenkins]', :delayed - #not_if { ::File.exist?("#{node['jenkins']['master']['home']}/plugins/#{plugin}.jpi") } - # end -} - -if !node['application_attributes']['jenkins_auth_set'] - jenkins_command 'safe-restart' - jenkins_private_key_credentials admin_vault['username'] do - id '1671945-9fa7-4d24-ac87-51ea3b2aef4c' - description admin_vault['username'] - private_key admin_vault['private_key'].strip - end -end - -# The Jenkins service user that this cookbook uses MUST exist in our directory -mu_master_user admin_vault['username'] do - realname admin_vault['username'] -# email $MU_CFG['jenkins']['admin_email'] || $MU_CFG['admin_email'] - email "mu-developers@googlegroups.com" -end - -# Add the admin user only if it has not been added already then notify the resource -# to configure the permissions for the admin user. Note that we check for existence of jenkins_auth_set, -# not value -jenkins_user admin_vault['username'] do - full_name admin_vault['username'] - email "mu-developers@googlegroups.com" - public_keys [admin_vault['public_key'].strip] - #not_if { node['application_attributes'].attribute?('jenkins_auth_set') } -end - - -# Configure the permissions so that login is required and the admin user is an administrator -# after this point the private key will be required to execute jenkins scripts (including querying -# if users exist) so we notify the `set the security_enabled flag` resource to set this up. -# Also note that since Jenkins 1.556 the private key cannot be used until after the admin user -# has been added to the security realm -uidsearch = "uid={0}" -uidsearch = "sAMAccountName={0}" if $MU_CFG['ldap']['type'] == "Active Directory" -membersearch = "(| (member={0}) (uniqueMember={0}) (memberUid={1}))" -membersearch = "memberUid={0}" if $MU_CFG['ldap']['type'] == "389 Directory Services" -bind_creds = chef_vault_item($MU_CFG['ldap']['bind_creds']['vault'], $MU_CFG['ldap']['bind_creds']['item']) -jenkins_admins = ::MU::Master.listUsers.delete_if { |_u, data| !data['admin'] }.keys -#jenkins_regular = ::MU::Master.listUsers.delete_if { |u, data| data['admin'] or u == "jenkins" }.keys -regular_user_perms = ["Item.BUILD", "Item.CREATE", "Item.DISCOVER", "Item.READ"] -jenkins_script 'configure_jenkins_auth' do - command <<-EOH.gsub(/^ {4}/, '') - import jenkins.model.* - import hudson.security.* - import org.jenkinsci.plugins.* - def instance = Jenkins.getInstance() - def hudsonRealm = new HudsonPrivateSecurityRealm(false) - String groupSearchFilter = 'memberUid={0}' - SecurityRealm ldapRealm = new LDAPSecurityRealm(server='ldap://#{$MU_CFG['ldap']['dcs'].first}', rootDN = '#{$MU_CFG['ldap']['base_dn']}', userSearchBase='#{$MU_CFG['ldap']['user_ou'].sub(/,.*/, "")}', userSearch="#{uidsearch}", groupSearchBase='#{$MU_CFG['ldap']['group_ou'].sub(/,.*/, "")}', groupSearchFilter="", groupMembershipFilter = '#{membersearch}', managerDN = '#{bind_creds[$MU_CFG['ldap']['bind_creds']['username_field']]}', managerPasswordSecret = '#{bind_creds[$MU_CFG['ldap']['bind_creds']['password_field']]}', inhibitInferRootDN = false, disableMailAddressResolver = false, cache = null) - instance.setSecurityRealm(ldapRealm) - def strategy = new ProjectMatrixAuthorizationStrategy() - strategy.add(Jenkins.ADMINISTER, "#{$MU_CFG['ldap']['admin_group_name']}") - strategy.add(Jenkins.ADMINISTER, "#{admin_vault['username']}") - #{jenkins_admins.map { |u| "strategy.add(Jenkins.ADMINISTER, \"#{u}\")" }.join("\n")} - strategy.add(Jenkins.READ, "authenticated") - #{regular_user_perms.map { |p| "strategy.add(hudson.model.#{p}, \"authenticated\")" }.join("\n")} - instance.setAuthorizationStrategy(strategy) - instance.save() - EOH -# not_if "grep managerDN #{node['jenkins']['master']['home']}/config.xml | grep #{bind_creds[$MU_CFG['ldap']['bind_creds']['username_field']]}" - notifies :run, 'ruby_block[configure_jenkins_auth_set]', :immediately - action :nothing unless !::File.size?("#{node['jenkins']['master']['home']}/config.xml") or !::File.read("#{node['jenkins']['master']['home']}/config.xml").match(bind_creds[$MU_CFG['ldap']['bind_creds']['username_field']]) -end - -file "#{node['jenkins']['master']['home']}/user-list-chef-guard" do - content " -#{jenkins_admins.map { |u| "strategy.add(Jenkins.ADMINISTER, \"#{u}\")" }.join("\n")} -#{regular_user_perms.map { |p| "strategy.add(Jenkins.#{p}, \"authenticated\")" }.join("\n")} -#{bind_creds[$MU_CFG['ldap']['bind_creds']['username_field']]} -" - notifies :execute, "jenkins_script[configure_jenkins_auth]", :immediately -end - -# Set the security enabled flag and set the run_state to use the configured private key -ruby_block 'configure_jenkins_auth_set' do - block do - node.run_state[:jenkins_private_key] = admin_vault['private_key'].strip - node.normal['application_attributes']['jenkins_auth_set'] = true - node.save - end - action :nothing -end - - - -# Configure users from the vault -#node['jenkins_users'].each { |user| -# user_vault = chef_vault_item(user[:vault], user[:vault_item]) -# -# # XXX This is dangerous. What if we stupidly step on the account of a -# # "real" user? -# ::MU::Master::LDAP.manageUser(user[:user_name], name: user[:fullname], password: user_vault[user[:user_name]+"_password"], admin: false, email: user[:email]) -# jenkins_user user[:user_name] do -# full_name user[:fullname] -# email user[:email] -# password user_vault["#{user[:user_name]}_password"] -# sensitive true -# end -#} diff --git a/cookbooks/mu-jenkins/recipes/node-ssh-config.rb b/cookbooks/mu-jenkins/recipes/node-ssh-config.rb deleted file mode 100644 index d0c29581d..000000000 --- a/cookbooks/mu-jenkins/recipes/node-ssh-config.rb +++ /dev/null @@ -1,54 +0,0 @@ -# -# Cookbook Name:: mu-jenkins -# Recipe:: node-ssh-config -# -# Copyright 2015, eGlobalTech, Inc -# -# All rights reserved - Do Not Redistribute -# - -include_recipe 'mu-jenkins::public_key' -include_recipe 'mu-tools::disable-requiretty' -include_recipe 'chef-vault' - -ssh_vault = chef_vault_item(node['jenkins_ssh_vault']['vault'], node['jenkins_ssh_vault']['item']) - -case node['platform'] - when platform_family?('rhel') - if platform?("centos") - ssh_user = "root" if node['platform_version'].to_i == 6 - ssh_user = "centos" if node['platform_version'].to_i == 7 - else - ssh_user = "ec2-user" - end - - directory "#{node['jenkins']['master']['home']}/.ssh" do - owner "jenkins" - group "jenkins" - mode 0700 - end - - ssh_key_path = "#{node['jenkins']['master']['home']}/.ssh/jenkins_ssh" - - template "#{node['jenkins']['master']['home']}/.ssh/config" do - source "ssh_config.erb" - owner "jenkins" - group "jenkins" - mode 0600 - variables( - :ssh_user => ssh_user, - :ssh_key_path => ssh_key_path, - :ssh_urls => node['jenkins_ssh_urls'] - ) - end - - file ssh_key_path do - owner "jenkins" - group "jenkins" - mode 0400 - content ssh_vault['private_key'].strip - sensitive true - end - else - Chef::Log.info("Unsupported platform #{node['platform']}") -end diff --git a/cookbooks/mu-jenkins/recipes/public_key.rb b/cookbooks/mu-jenkins/recipes/public_key.rb deleted file mode 100644 index 157fcd8c0..000000000 --- a/cookbooks/mu-jenkins/recipes/public_key.rb +++ /dev/null @@ -1,24 +0,0 @@ -# -# Cookbook Name:: mu-jenkins -# Recipe:: default -# -# Copyright 2015, eGlobalTech, Inc -# -# All rights reserved - Do Not Redistribute -# - -case node['platform'] - when platform_family?('rhel') - include_recipe 'chef-vault' - - ssh_vault = chef_vault_item(node['jenkins_ssh_vault']['vault'], node['jenkins_ssh_vault']['item']) - - ssh_authorized_keys = "/root/.ssh/authorized_keys" if node['platform_version'].to_i == 6 - ssh_authorized_keys = "/home/centos/.ssh/authorized_keys" if node['platform_version'].to_i == 7 - - execute "echo '#{ssh_vault['public_key'].strip}' >> #{ssh_authorized_keys}" do - not_if "grep '^#{ssh_vault['public_key'].strip}$' #{ssh_authorized_keys}" - end - else - Chef::Log.info("Unsupported platform #{node['platform']}") -end diff --git a/cookbooks/mu-jenkins/templates/default/example_job.config.xml.erb b/cookbooks/mu-jenkins/templates/default/example_job.config.xml.erb deleted file mode 100644 index 904629480..000000000 --- a/cookbooks/mu-jenkins/templates/default/example_job.config.xml.erb +++ /dev/null @@ -1,24 +0,0 @@ - - - - - false - - - true - false - false - false - - false - - - <%= @ssh_user %>@<%= @node_ip %>:22 - sudo su - << eoh -chef-client -eoh - - - - - \ No newline at end of file diff --git a/cookbooks/mu-jenkins/templates/default/org.jvnet.hudson.plugins.SSHBuildWrapper.xml.erb b/cookbooks/mu-jenkins/templates/default/org.jvnet.hudson.plugins.SSHBuildWrapper.xml.erb deleted file mode 100644 index be2ce14a3..000000000 --- a/cookbooks/mu-jenkins/templates/default/org.jvnet.hudson.plugins.SSHBuildWrapper.xml.erb +++ /dev/null @@ -1,14 +0,0 @@ - - - - - <%= @node_ip %> - 22 - <%= @ssh_user %> - - <%= @ssh_key_path %> - 0 - false - - - \ No newline at end of file diff --git a/cookbooks/mu-jenkins/templates/default/ssh_config.erb b/cookbooks/mu-jenkins/templates/default/ssh_config.erb deleted file mode 100644 index 7c970d6aa..000000000 --- a/cookbooks/mu-jenkins/templates/default/ssh_config.erb +++ /dev/null @@ -1,6 +0,0 @@ -<% @ssh_urls.each { |url| %> -Host <%= url %> - User <%= @ssh_user %> - IdentityFile <%= @ssh_key_path %> - StrictHostKeyChecking no -<% } %> diff --git a/cookbooks/mu-master/Berksfile b/cookbooks/mu-master/Berksfile index ea0f51c19..61762674c 100644 --- a/cookbooks/mu-master/Berksfile +++ b/cookbooks/mu-master/Berksfile @@ -4,7 +4,7 @@ source chef_repo: ".." metadata # Mu Cookbooks -cookbook 'nagios' +cookbook 'mu-nagios' , '~> 8.2.0', git: "https://github.com/cloudamatic/mu-nagios.git" cookbook 'mu-utility' cookbook 'mu-tools' cookbook 'mu-firewall' @@ -20,4 +20,4 @@ cookbook 'vault-cluster', '~> 2.1.0' cookbook 'consul-cluster', '~> 2.0.0' cookbook 'hostsfile', '~> 3.0.1' cookbook 'chef-vault', '~> 3.1.1' -cookbook 'apache2', '< 4.0' \ No newline at end of file +cookbook 'chef-sugar' diff --git a/cookbooks/mu-master/files/default/check_mem.pl b/cookbooks/mu-master/files/default/check_mem.pl old mode 100755 new mode 100644 diff --git a/cookbooks/mu-master/files/default/cloudamatic.png b/cookbooks/mu-master/files/default/cloudamatic.png old mode 100755 new mode 100644 diff --git a/cookbooks/mu-master/metadata.rb b/cookbooks/mu-master/metadata.rb index 13a3e4326..c3cddcf93 100644 --- a/cookbooks/mu-master/metadata.rb +++ b/cookbooks/mu-master/metadata.rb @@ -7,13 +7,13 @@ source_url 'https://github.com/cloudamatic/mu' issues_url 'https://github.com/cloudamatic/mu/issues' chef_version '>= 12.1' if respond_to?(:chef_version) -version '0.9.3' +version '0.9.6' -%w( centos ).each do |os| +%w( centos amazon redhat ).each do |os| supports os end -depends 'nagios' +depends 'mu-nagios' depends 'nrpe', '~> 2.0.3' depends 'mu-utility' depends 'mu-tools' @@ -25,6 +25,7 @@ depends 'mu-firewall' depends 'vault-cluster', '~> 2.1.0' depends 'consul-cluster', '~> 2.0.0' +depends 'chef-sugar' # undeclared dependency of consul 2.1, which can't be upgraded without creating a conflict with consul-cluster and vault-cluster -zr2d2 depends 'hostsfile', '~> 3.0.1' depends 'chef-vault', '~> 3.1.1' -depends 'apache2', '< 4.0' +depends 'apache2', '< 6.0.0' diff --git a/cookbooks/mu-master/recipes/389ds.rb b/cookbooks/mu-master/recipes/389ds.rb index dade7d2ba..3dc09141e 100644 --- a/cookbooks/mu-master/recipes/389ds.rb +++ b/cookbooks/mu-master/recipes/389ds.rb @@ -42,7 +42,7 @@ } service_name = "dirsrv" -if node['platform_version'].to_i >= 7 +if node['platform_version'].to_i >= 7 || (node['platform_family'] == 'amazon' && node['platform_version'].to_i == 2) service_name = service_name + "@" + $MU_CFG["hostname"] end diff --git a/cookbooks/mu-master/recipes/basepackages.rb b/cookbooks/mu-master/recipes/basepackages.rb index 7357c9296..b0a209e8a 100644 --- a/cookbooks/mu-master/recipes/basepackages.rb +++ b/cookbooks/mu-master/recipes/basepackages.rb @@ -27,24 +27,42 @@ rpms = {} dpkgs = {} -if platform_family?("rhel") - basepackages = ["vim-enhanced", "zip", "unzip", "java-1.8.0-openjdk", "libxml2-devel", "libxslt-devel", "cryptsetup-luks", "python-pip", "lsof", "mlocate", "strace", "nmap", "openssl-devel", "readline-devel", "python-devel", "diffutils", "patch", "bind-utils", "httpd-tools", "mailx", "openssl", "libyaml", "graphviz", "ImageMagick-devel", "graphviz-devel", "jq", "vim", "libffi-devel"] +rhelbase = ["vim-enhanced", "zip", "unzip", "java-1.8.0-openjdk", "libxml2-devel", "libxslt-devel", "cryptsetup-luks", "python-pip", "lsof", "mlocate", "strace", "nmap", "openssl-devel", "readline-devel", "python-devel", "diffutils", "patch", "bind-utils", "httpd-tools", "mailx", "openssl", "libyaml", "graphviz", "ImageMagick-devel", "graphviz-devel", "jq", "vim", "libffi-devel"] +debianbase = [] # Bill is hopeful about the future... - if node['platform_version'].to_i < 6 or node['platform_version'].to_i >= 8 - raise "Mu Masters on RHEL-family hosts must be equivalent to RHEL6 or RHEL7" +case node['platform_family'] +when 'rhel' + basepackages = rhelbase - # RHEL6, CentOS6, Amazon Linux - elsif node['platform_version'].to_i < 7 + case node['platform_version'].split('.')[0].to_i + when 6 basepackages.concat(["java-1.5.0-gcj", "mysql-server", "autoconf"]) - basepackages << "gecode-devel" if node['platform'] == "amazon" - # RHEL7, CentOS7 - elsif node['platform_version'].to_i < 8 + when 7 basepackages.concat(["gecode-devel", "mariadb", "qt", "qt-x11", "iptables-services"]) + + when 8 + raise "Mu currently does not support RHEL 8... but I assume it will in the future... But I am Bill and I am hopeful about the future." + else + raise "Mu does not support RHEL #{node['platform_version']}" + end + +when 'amazon' + basepackages = rhelbase + + case node['platform_version'].split('.')[0].to_i + when 1, 6 + basepackages.concat(['java-1.5.0-gcj', 'mysql-server', 'autoconf', 'gecode-devel']) + + when 2 + basepackages.concat(["gecode-devel", "mariadb", "qt", "qt-x11", "iptables-services"]) + + else + raise "Mu does not support Amazon #{node['platform_version']}" end else - raise "Mu Masters are currently only supported on RHEL-family hosts." + raise "Mu Masters are currently only supported on RHEL and Amazon family hosts." end package basepackages @@ -56,3 +74,5 @@ package removepackages do action :remove end + +basepackages = ["git", "curl", "diffutils", "patch", "gcc", "gcc-c++", "make", "postgresql-devel", "libyaml", "libffi-devel", "tcl", "tk"] diff --git a/cookbooks/mu-master/recipes/default.rb b/cookbooks/mu-master/recipes/default.rb index 429c8a020..f1777e96b 100644 --- a/cookbooks/mu-master/recipes/default.rb +++ b/cookbooks/mu-master/recipes/default.rb @@ -17,9 +17,44 @@ # limitations under the License. # XXX this is nonsense if we're not in AWS -response = Net::HTTP.get_response(URI("http://169.254.169.254/latest/meta-data/instance-id")) -instance_id = response.body -search_domains = ["ec2.internal", "sclearerver.#{instance_id}.platform-mu", "platform-mu"] +instance_id = node.name +search_domains = ["platform-mu"] +if node['ec2'] + response = Net::HTTP.get_response(URI("http://169.254.169.254/latest/meta-data/instance-id")) + instance_id = response.body + search_domains = ["ec2.internal", "server.#{instance_id}.platform-mu", "platform-mu"] +elsif node['gce'] + instance_id = node['gce']['instance']['name'] + domains = node['gce']['instance']['hostname'].split(/\./) + domains.shift + search_domains = [] + begin + search_domains << domains.join(".")+"." + domains.shift + end while domains.size > 1 + search_domains << "google.internal." +end + +if ::File.exist?("/etc/sudoers.d/waagent") + sshgroup = if node['platform'] == "centos" + "centos" + elsif node['platform'] == "ubuntu" + "ubuntu" + elsif node['platform'] == "windows" + "windows" + else + "root" + end + + File.readlines("/etc/sudoers.d/waagent").each { |l| + l.chomp! + user = l.sub(/ .*/, '') + group sshgroup do + members user + append true + end + } +end include_recipe 'mu-master::init' include_recipe 'mu-master::basepackages' @@ -27,7 +62,7 @@ include_recipe 'mu-master::ssl-certs' include_recipe 'mu-master::vault' include_recipe 'mu-tools::gcloud' -#include_recipe 'mu-master::eks-kubectl' +#include_recipe 'mu-master::kubectl' master_ips = get_mu_master_ips master_ips << "127.0.0.1" @@ -128,7 +163,15 @@ include_recipe "mu-master::update_nagios_only" if !node['update_nagios_only'] - package "nagios-plugins-all" + + package %w(nagios-plugins-breeze nagios-plugins-by_ssh nagios-plugins-cluster nagios-plugins-dhcp nagios-plugins-dig nagios-plugins-disk nagios-plugins-disk_smb nagios-plugins-dns nagios-plugins-dummy nagios-plugins-file_age nagios-plugins-flexlm nagios-plugins-fping nagios-plugins-game nagios-plugins-hpjd nagios-plugins-http nagios-plugins-icmp nagios-plugins-ide_smart nagios-plugins-ircd nagios-plugins-ldap nagios-plugins-load nagios-plugins-log nagios-plugins-mailq nagios-plugins-mrtg nagios-plugins-mrtgtraf nagios-plugins-nagios nagios-plugins-nt nagios-plugins-ntp nagios-plugins-ntp-perl nagios-plugins-nwstat nagios-plugins-oracle nagios-plugins-overcr nagios-plugins-pgsql nagios-plugins-ping nagios-plugins-procs nagios-plugins-real nagios-plugins-rpc nagios-plugins-sensors nagios-plugins-smtp nagios-plugins-snmp nagios-plugins-ssh nagios-plugins-swap nagios-plugins-tcp nagios-plugins-time nagios-plugins-ups nagios-plugins-users nagios-plugins-wave) do + action :install + end + + package %w(nagios-plugins-mysql) do + action :install + not_if { node['platform'] == 'amazon' } + end directory "/home/nagios" do owner "nagios" @@ -179,14 +222,23 @@ include_recipe "apache2::mod_proxy" include_recipe "apache2::mod_proxy_http" include_recipe "apache2::mod_rewrite" - include_recipe "apache2::mod_ldap" - include_recipe "apache2::mod_authnz_ldap" + + if node['platform_family'] == "rhel" and node['platform_version'].split('.')[0].to_i == 6 + package "httpd24-mod_ldap" + apache_module 'ldap' do + conf true + end + else + include_recipe "apache2::mod_authnz_ldap" + end + apache_site "default" do enable false end execute "Allow net connect to local for apache" do command "/usr/sbin/setsebool -P httpd_can_network_connect on" not_if "/usr/sbin/getsebool httpd_can_network_connect | grep -cim1 ^.*on$" + not_if "/sbin/getenforce | grep -cim1 disabled" notifies :reload, "service[apache2]", :delayed end @@ -251,9 +303,6 @@

Nagios monitoring GUI

-

- Jenkins interface GUI -

Mu API documentation

diff --git a/cookbooks/mu-master/recipes/firewall-holes.rb b/cookbooks/mu-master/recipes/firewall-holes.rb index 6d8322191..bb9d69a37 100644 --- a/cookbooks/mu-master/recipes/firewall-holes.rb +++ b/cookbooks/mu-master/recipes/firewall-holes.rb @@ -20,7 +20,7 @@ # TODO Move all mu firewall rules to a mu specific chain firewall_rule "MU Master default ports" do - port [2260, 7443, 8443, 9443, 10514, 443, 80, 25] + port [MU.mommaCatPort, 7443, 8443, 9443, 10514, 443, 80, 25] end local_chef_ports = [4321, 9463, 9583, 16379, 8983, 8000, 9680, 9683, 9090, 5432] diff --git a/cookbooks/mu-master/recipes/init.rb b/cookbooks/mu-master/recipes/init.rb index a38ec1378..100ad4e34 100644 --- a/cookbooks/mu-master/recipes/init.rb +++ b/cookbooks/mu-master/recipes/init.rb @@ -36,7 +36,7 @@ # XXX We want to be able to override these things when invoked from chef-apply, # but, like, how? CHEF_SERVER_VERSION="12.17.15-1" -CHEF_CLIENT_VERSION="14.11.21" +CHEF_CLIENT_VERSION="14.13.11" KNIFE_WINDOWS="1.9.0" MU_BASE="/opt/mu" MU_BRANCH="master" # GIT HOOK EDITABLE DO NOT TOUCH @@ -171,43 +171,60 @@ rpms = {} dpkgs = {} -elversion = node['platform_version'].to_i > 2000 ? 6 : node['platform_version'].to_i -if platform_family?("rhel") - basepackages = ["git", "curl", "diffutils", "patch", "gcc", "gcc-c++", "make", "postgresql-devel", "libyaml", "libffi-devel"] -# package epel-release-6-8.9.amzn1.noarch (which is newer than epel-release-6-8.noarch) is already installed +elversion = node['platform_version'].split('.')[0] - rpms = { - "epel-release" => "http://dl.fedoraproject.org/pub/epel/epel-release-latest-#{elversion}.noarch.rpm", - "chef-server-core" => "https://packages.chef.io/files/stable/chef-server/#{CHEF_SERVER_VERSION.sub(/\-\d+$/, "")}/el/#{elversion}/chef-server-core-#{CHEF_SERVER_VERSION}.el#{elversion}.x86_64.rpm" - } +rhelbase = ["git", "curl", "diffutils", "patch", "gcc", "gcc-c++", "make", "postgresql-devel", "libyaml", "libffi-devel", "tcl", "tk"] +case node['platform_family'] +when 'rhel' - if elversion < 6 or elversion >= 8 - raise "Mu Masters on RHEL-family hosts must be equivalent to RHEL6 or RHEL7 (got #{elversion})" + basepackages = rhelbase - # RHEL6, CentOS6, Amazon Linux - elsif elversion < 7 - basepackages.concat(["mysql-devel"]) - rpms["ruby25"] = "https://s3.amazonaws.com/cloudamatic/muby-2.5.3-1.el6.x86_64.rpm" - + case node['platform_version'].split('.')[0].to_i + when 6 + basepackages.concat(["cryptsetup-luks", "mysql-devel", "centos-release-scl"]) removepackages = ["nagios"] - # RHEL7, CentOS7 - elsif elversion < 8 - basepackages.concat(["libX11", "tcl", "tk", "mariadb-devel", "cryptsetup"]) - rpms["ruby25"] = "https://s3.amazonaws.com/cloudamatic/muby-2.5.3-1.el7.x86_64.rpm" - removepackages = ["nagios", "firewalld"] - end - # Amazon Linux - if node['platform_version'].to_i > 2000 - basepackages.concat(["compat-libffi5"]) - rpms.delete("epel-release") + when 7 + basepackages.concat(['libX11', 'mariadb-devel', 'cryptsetup']) + removepackages = ['nagios', 'firewalld'] + + when 8 + raise "Mu currently does not support RHEL 8... but I assume it will in the future... But I am Bill and I am hopeful about the future." + else + raise "Mu does not support RHEL #{node['platform_version']} (matched on #{node['platform_version'].split('.')[0]})" end +when 'amazon' + basepackages = rhelbase + rpms.delete('epel-release') + + case node['platform_version'].split('.')[0] + when '1', '6' #REALLY THIS IS AMAZON LINUX 1, BUT IT IS BASED OFF OF RHEL 6 + basepackages.concat(['mysql-devel', 'libffi-devel']) + basepackages.delete('tk') + removepackages = ["nagios"] + + when '2' + basepackages.concat(['libX11', 'mariadb-devel', 'cryptsetup', 'ncurses-devel', 'ncurses-compat-libs', 'iptables-services']) + removepackages = ['nagios', 'firewalld'] + elversion = '7' #HACK TO FORCE AMAZON LINUX 2 TO BE TREATED LIKE RHEL 7 + + else + raise "Mu Masters on Amazon-family hosts must be equivalent to Amazon Linux 1 or 2 (got #{node['platform_version'].split('.')[0]})" + end else - raise "Mu Masters are currently only supported on RHEL-family hosts." + raise "Mu Masters are currently only supported on RHEL and Amazon family hosts (got #{node['platform_family']})." end +rpms = { + "epel-release" => "http://dl.fedoraproject.org/pub/epel/epel-release-latest-#{elversion}.noarch.rpm", + "chef-server-core" => "https://packages.chef.io/files/stable/chef-server/#{CHEF_SERVER_VERSION.sub(/\-\d+$/, "")}/el/#{elversion}/chef-server-core-#{CHEF_SERVER_VERSION}.el#{elversion}.x86_64.rpm" +} + +rpms["ruby25"] = "https://s3.amazonaws.com/cloudamatic/muby-2.5.3-1.el#{elversion}.x86_64.rpm" +rpms["python27"] = "https://s3.amazonaws.com/cloudamatic/muthon-2.7.16-1.el#{elversion}.x86_64.rpm" + package basepackages directory MU_BASE do @@ -284,23 +301,26 @@ # REMOVE OLD RUBYs execute "clean up old Ruby 2.1.6" do command "rm -rf /opt/rubies/ruby-2.1.6" + ignore_failure true only_if { ::Dir.exist?("/opt/rubies/ruby-2.1.6") } end -yum_package 'ruby23-2.3.1-1.el7.centos.x86_64' do - action :purge -end - execute "Kill ruby-2.3.1" do command "yum erase ruby23-2.3.1-1.el7.centos.x86_64 -y; rpm -e ruby23" + ignore_failure true only_if { ::Dir.exist?("/opt/rubies/ruby-2.3.1") } end execute "clean up old ruby-2.3.1" do command "rm -rf /opt/rubies/ruby-2.3.1" + ignore_failure true only_if { ::Dir.exist?("/opt/rubies/ruby-2.3.1") } end +execute "yum makecache" do + action :nothing +end + # Regular old rpm-based installs rpms.each_pair { |pkg, src| rpm_package pkg do @@ -308,6 +328,9 @@ if pkg == "ruby25" options '--prefix=/opt/rubies/' end + if pkg == "epel-release" + notifies :run, "execute[yum makecache]", :immediately + end if pkg == "chef-server-core" notifies :stop, "service[iptables]", :before if File.size?("/etc/opscode/chef-server.rb") @@ -320,6 +343,7 @@ end end } + package ["jq"] do ignore_failure true # sometimes we can't see EPEL immediately end @@ -369,7 +393,7 @@ end end -["mu-aws-setup", "mu-cleanup", "mu-configure", "mu-deploy", "mu-firewall-allow-clients", "mu-gen-docs", "mu-load-config.rb", "mu-node-manage", "mu-tunnel-nagios", "mu-upload-chef-artifacts", "mu-user-manage", "mu-ssh"].each { |exe| +["mu-cleanup", "mu-configure", "mu-deploy", "mu-firewall-allow-clients", "mu-gen-docs", "mu-load-config.rb", "mu-node-manage", "mu-tunnel-nagios", "mu-upload-chef-artifacts", "mu-user-manage", "mu-ssh", "mu-adopt", "mu-azure-setup", "mu-gcp-setup", "mu-aws-setup"].each { |exe| link "#{MU_BASE}/bin/#{exe}" do to "#{MU_BASE}/lib/bin/#{exe}" end @@ -382,6 +406,12 @@ mode 0755 end +bash "install modules for our built-in Python" do + code <<-EOH + /usr/local/python-current/bin/pip install -r #{MU_BASE}/lib/requirements.txt + EOH +end + ["/usr/local/ruby-current", "/opt/chef/embedded"].each { |rubydir| gembin = rubydir+"/bin/gem" gemdir = Dir.glob("#{rubydir}/lib/ruby/gems/?.?.?/gems").last @@ -424,26 +454,15 @@ execute "rm -rf #{gemdir}/knife-windows-#{Regexp.last_match[1]}" } -# XXX rely on bundler to get this right for us -# gem_package "#{rubydir} knife-windows #{KNIFE_WINDOWS} #{gembin}" do -# gem_binary gembin -# package_name "knife-windows" -# version KNIFE_WINDOWS -# notifies :restart, "service[chef-server]", :delayed if rubydir == "/opt/opscode/embedded" -# # XXX notify mommacat if we're *not* in chef-apply... RUNNING_STANDALONE -# end - -# execute "Patch #{rubydir}'s knife-windows for Cygwin SSH bootstraps" do -# cwd "#{gemdir}/knife-windows-#{KNIFE_WINDOWS}" -# command "patch -p1 < #{MU_BASE}/lib/install/knife-windows-cygwin-#{KNIFE_WINDOWS}.patch" -# not_if "grep -i 'locate_config_value(:cygwin)' #{gemdir}/knife-windows-#{KNIFE_WINDOWS}/lib/chef/knife/bootstrap_windows_base.rb" -# notifies :restart, "service[chef-server]", :delayed if rubydir == "/opt/opscode/embedded" -# only_if { ::Dir.exist?(gemdir) } - # XXX notify mommacat if we're *not* in chef-apply... RUNNING_STANDALONE -# end end } +# This is mostly to make sure Berkshelf has a clean and current environment to +# live with. +execute "/usr/local/ruby-current/bin/bundle clean --force" do + cwd "#{MU_BASE}/lib/modules" + only_if { RUNNING_STANDALONE } +end # Get a 'mu' Chef org in place and populate it with artifacts directory "/root/.chef" @@ -544,7 +563,7 @@ file "#{MU_BASE}/etc/mu.rc" do content %Q{export MU_INSTALLDIR="#{MU_BASE}" export MU_DATADIR="#{MU_BASE}/var" -export PATH="#{MU_BASE}/bin:/usr/local/ruby-current/bin:${PATH}:/opt/opscode/embedded/bin" +export PATH="#{MU_BASE}/bin:/usr/local/ruby-current/bin:/usr/local/python-current/bin:${PATH}:/opt/opscode/embedded/bin" } mode 0644 action :create_if_missing @@ -559,3 +578,10 @@ notifies :run, "bash[fix #{rubydir} gem permissions]", :delayed end } +bash "fix misc permissions" do + code <<-EOH + find #{MU_BASE}/lib -not -path "#{MU_BASE}/.git" -type d -exec chmod go+r {} \\; + find #{MU_BASE}/lib -not -path "#{MU_BASE}/.git/*" -type f -exec chmod go+r {} \\; + chmod go+rx #{MU_BASE}/lib/bin/* #{MU_BASE}/lib/extras/*-stock-* #{MU_BASE}/lib/extras/vault_tools/*.sh + EOH +end diff --git a/cookbooks/mu-master/recipes/eks-kubectl.rb b/cookbooks/mu-master/recipes/kubectl.rb similarity index 64% rename from cookbooks/mu-master/recipes/eks-kubectl.rb rename to cookbooks/mu-master/recipes/kubectl.rb index db8224c1a..03b9fa298 100644 --- a/cookbooks/mu-master/recipes/eks-kubectl.rb +++ b/cookbooks/mu-master/recipes/kubectl.rb @@ -1,5 +1,5 @@ # Cookbook Name:: mu-master -# Recipe:: eks-kubectl +# Recipe:: kubectl # # Copyright:: Copyright (c) 2018 eGlobalTech, Inc., all rights reserved # @@ -23,19 +23,13 @@ # templates. # remote_file "/opt/mu/bin/kubectl" do - source "https://amazon-eks.s3-us-west-2.amazonaws.com/1.10.3/2018-07-26/bin/linux/amd64/kubectl" + source "https://amazon-eks.s3-us-west-2.amazonaws.com/1.14.6/2019-08-22/bin/linux/amd64/kubectl" mode 0755 - not_if "test -f /opt/mu/bin/kubectl" + not_if "test -f /opt/mu/bin/kubectl && kubectl version --short | grep 1.14.6" end remote_file "/opt/mu/bin/aws-iam-authenticator" do - source "https://amazon-eks.s3-us-west-2.amazonaws.com/1.10.3/2018-07-26/bin/linux/amd64/aws-iam-authenticator" + source "https://amazon-eks.s3-us-west-2.amazonaws.com/1.14.6/2019-08-22/bin/linux/amd64/aws-iam-authenticator" mode 0755 not_if "test -f /opt/mu/bin/aws-iam-authenticator" end - -# in brand new accounts where no load balancer has been created, something -# has to do this before EKS has to, because by default it can't -execute "aws iam create-service-linked-role --aws-service-name 'elasticloadbalancing.amazonaws.com'" do - not_if "aws iam list-roles | grep /aws-service-role/elasticloadbalancing.amazonaws.com/" -end diff --git a/cookbooks/mu-master/recipes/sssd.rb b/cookbooks/mu-master/recipes/sssd.rb index d3695b0a3..378ccc52b 100644 --- a/cookbooks/mu-master/recipes/sssd.rb +++ b/cookbooks/mu-master/recipes/sssd.rb @@ -58,7 +58,8 @@ start_command "sh -x /etc/init.d/oddjobd start" if %w{redhat centos}.include?(node['platform']) && node['platform_version'].to_i == 6 # seems to actually work action [:enable, :start] end -execute "/usr/sbin/authconfig --disablenis --disablecache --disablewinbind --disablewinbindauth --enablemkhomedir --disablekrb5 --enablesssd --enablesssdauth --enablelocauthorize --disableforcelegacy --disableldap --disableldapauth --updateall" do +package "authconfig" +execute "LC_ALL=C /usr/sbin/authconfig --disablenis --disablecache --disablewinbind --disablewinbindauth --enablemkhomedir --disablekrb5 --enablesssd --enablesssdauth --enablelocauthorize --disableforcelegacy --disableldap --disableldapauth --updateall" do notifies :restart, "service[oddjobd]", :immediately notifies :reload, "service[sshd]", :delayed not_if "grep pam_sss.so /etc/pam.d/password-auth" diff --git a/cookbooks/mu-master/recipes/update_nagios_only.rb b/cookbooks/mu-master/recipes/update_nagios_only.rb index 5e6cd578b..465e908b3 100644 --- a/cookbooks/mu-master/recipes/update_nagios_only.rb +++ b/cookbooks/mu-master/recipes/update_nagios_only.rb @@ -16,8 +16,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -include_recipe "nagios::server_source" -include_recipe "nagios" +include_recipe "mu-nagios::server_source" +include_recipe "mu-nagios" include_recipe 'mu-master::firewall-holes' if $MU_CFG.has_key?('ldap') @@ -49,7 +49,7 @@ content "checkconfig=\"false\"\n" mode 0600 end -include_recipe "nagios" +include_recipe "mu-nagios" # scrub our old stuff if it's around ["nagios_fifo", "nagios_more_selinux"].each { |policy| @@ -139,15 +139,15 @@ ["/usr/lib/cgi-bin"].each { |cgidir| if Dir.exist?(cgidir) - execute "chcon -R -h -t httpd_sys_script_exec_t #{cgidir}" do + execute "chcon -R -h system_u:object_r:httpd_sys_script_exec_t #{cgidir}" do not_if "ls -aZ #{cgidir} | grep ':httpd_sys_script_exec_t:'" notifies :reload, "service[apache2]", :delayed end end } if File.exist?("/usr/lib64/nagios/plugins/check_nagios") - execute "chcon -R -h -t nagios_unconfined_plugin_exec_t /usr/lib64/nagios/plugins/check_nagios" do - not_if "ls -aZ /usr/lib64/nagios/plugins/check_nagios | grep ':nagios_unconfined_plugin_exec_t:'" + execute "chcon -R -h system_u:object_r:nagios_unconfined_plugin_exec_t /usr/lib64/nagios/plugins/check_nagios" do + not_if "ls -aZ /usr/lib64/nagios/plugins/check_nagios | grep 'object_r:nagios_'" end end diff --git a/cookbooks/mu-master/templates/default/mu.rc.erb b/cookbooks/mu-master/templates/default/mu.rc.erb index 14e6679e3..d626493ce 100644 --- a/cookbooks/mu-master/templates/default/mu.rc.erb +++ b/cookbooks/mu-master/templates/default/mu.rc.erb @@ -1,7 +1,7 @@ # bash/sh environment support for Mu tools. Intended for the system (root) # user. Regular users get a .murc installed by mu-user-manage, from the template # in <%= @installdir %>/lib/install/user-dot-murc.erb -export PATH="<%= @installdir %>/bin:/usr/local/ruby-current/bin:${PATH}:/opt/opscode/embedded/bin" +export PATH="<%= @installdir %>/bin:/usr/local/ruby-current/bin:/usr/local/python-current/bin:${PATH}:/opt/opscode/embedded/bin" export MU_INSTALLDIR="<%= @installdir %>" export MU_DATADIR="<%= @installdir %>/var" <% if @repos %> diff --git a/cookbooks/mu-master/templates/default/web_app.conf.erb b/cookbooks/mu-master/templates/default/web_app.conf.erb index d7fb28f5a..1edc9236c 100644 --- a/cookbooks/mu-master/templates/default/web_app.conf.erb +++ b/cookbooks/mu-master/templates/default/web_app.conf.erb @@ -27,12 +27,8 @@ AllowEncodedSlashes off # Scratchpad, the Mu secret-sharer - ProxyPass /scratchpad https://localhost:2260/scratchpad - ProxyPassReverse /scratchpad https://localhost:2260/scratchpad - - # Jenkins CI web interface - ProxyPass /jenkins http://localhost:8080/jenkins - ProxyPassReverse /jenkins http://localhost:8080/jenkins + ProxyPass /scratchpad https://localhost:<%= MU.mommaCatPort.to_s %>/scratchpad + ProxyPassReverse /scratchpad https://localhost:<%= MU.mommaCatPort.to_s %>/scratchpad # Nagios web UI ProxyPass /nagios/ https://localhost:8443/nagios/ diff --git a/cookbooks/mu-master/templates/mods/ldap.conf.erb b/cookbooks/mu-master/templates/mods/ldap.conf.erb new file mode 100644 index 000000000..6333d06b8 --- /dev/null +++ b/cookbooks/mu-master/templates/mods/ldap.conf.erb @@ -0,0 +1,4 @@ + + SetHandler ldap-status + Require local + diff --git a/cookbooks/mu-php54/Berksfile b/cookbooks/mu-php54/Berksfile index 2a10e0e10..eca000e22 100644 --- a/cookbooks/mu-php54/Berksfile +++ b/cookbooks/mu-php54/Berksfile @@ -8,6 +8,5 @@ cookbook 'mu-utility' # Supermarket Cookbooks cookbook 'simple_iptables', '~> 0.8.0' -cookbook 'apache2', '< 4.0' cookbook 'mysql', '~> 8.5.1' -cookbook 'yum-epel', '~> 3.2.0' \ No newline at end of file +cookbook 'yum-epel', '~> 3.2.0' diff --git a/cookbooks/mu-php54/metadata.rb b/cookbooks/mu-php54/metadata.rb index e3eb8649b..c874aadfb 100644 --- a/cookbooks/mu-php54/metadata.rb +++ b/cookbooks/mu-php54/metadata.rb @@ -4,10 +4,9 @@ license 'BSD-3-Clause' description 'Installs/Configures php' -long_description IO.read(File.join(File.dirname(__FILE__), 'README.md')) source_url 'https://github.com/cloudamatic/mu' issues_url 'https://github.com/cloudamatic/mu/issues' -chef_version '>= 14.0' if respond_to?(:chef_version) +chef_version '>= 14.0' version '0.3.1' %w( centos ubuntu ).each do |os| @@ -16,6 +15,6 @@ depends 'mu-utility' depends 'simple_iptables', '~> 0.8.0' -depends 'apache2', '< 4.0' depends 'mysql', '~> 8.5.1' depends 'yum-epel', '~> 3.2.0' +depends 'apache2', '< 6.0.0' diff --git a/cookbooks/mu-php54/recipes/default.rb b/cookbooks/mu-php54/recipes/default.rb index 95c0abcc8..8448213db 100644 --- a/cookbooks/mu-php54/recipes/default.rb +++ b/cookbooks/mu-php54/recipes/default.rb @@ -24,7 +24,7 @@ case node['platform'] - when "centos" + when "centos", "amazon" include_recipe "yum-epel" include_recipe "mu-utility::remi" diff --git a/cookbooks/mu-splunk/templates/default/splunk-init.erb b/cookbooks/mu-splunk/templates/default/splunk-init.erb old mode 100755 new mode 100644 diff --git a/cookbooks/mu-tools/Berksfile b/cookbooks/mu-tools/Berksfile index 23171684d..0d27a0e57 100644 --- a/cookbooks/mu-tools/Berksfile +++ b/cookbooks/mu-tools/Berksfile @@ -4,7 +4,7 @@ source chef_repo: ".." metadata # Mu Cookbooks -cookbook "nagios" +cookbook 'mu-nagios' , '~> 8.2.0', git: "https://github.com/cloudamatic/mu-nagios.git" cookbook "mu-utility" cookbook "mu-splunk" cookbook "mu-firewall" @@ -18,4 +18,5 @@ cookbook "java", '~> 2.2.0' cookbook "windows", '~> 5.1.1' cookbook "chef-vault", '~> 3.1.1' cookbook "poise-python", '~> 1.7.0' -cookbook "yum-epel", '~> 3.2.0' \ No newline at end of file +cookbook "yum-epel", '~> 3.2.0' +cookbook 'selinux', '~> 3.0.0' diff --git a/cookbooks/mu-tools/libraries/helper.rb b/cookbooks/mu-tools/libraries/helper.rb index d05db7bdb..bdffed501 100644 --- a/cookbooks/mu-tools/libraries/helper.rb +++ b/cookbooks/mu-tools/libraries/helper.rb @@ -168,7 +168,14 @@ def get_first_nameserver end def get_deploy_secret - uri = URI("https://#{get_mu_master_ips.first}:2260/rest/bucketname") + cloud = if !get_aws_metadata("meta-data/instance-id").nil? + "AWS" + elsif !get_google_metadata("instance/name").nil? + "Google" +# elsif +# "Azure" + end + uri = URI("https://#{get_mu_master_ips.first}:2260/rest/bucketname/#{cloud}/#{node['credentials']}") http = Net::HTTP.new(uri.hostname, uri.port) http.use_ssl = true http.verify_mode = ::OpenSSL::SSL::VERIFY_NONE # XXX this sucks @@ -177,7 +184,7 @@ def get_deploy_secret secret = nil filename = mu_get_tag_value("MU-ID")+"-secret" - if !get_aws_metadata("meta-data/instance-id").nil? + if cloud == "AWS" resp = nil begin resp = s3.get_object(bucket: bucket, key: filename) @@ -187,18 +194,23 @@ def get_deploy_secret end Chef::Log.info("Fetch deploy secret from s3://#{bucket}/#{filename}") secret = resp.body.read - elsif !get_google_metadata("instance/name").nil? + elsif cloud == "Google" include_recipe "mu-tools::gcloud" + resp = nil ["/opt/google-cloud-sdk/bin/gsutil", "/bin/gsutil"].each { |gsutil| next if !File.exist?(gsutil) Chef::Log.info("Fetching deploy secret: #{gsutil} cp gs://#{bucket}/#{filename} -") - if File.exist?("/usr/bin/python2.7") - # secret = %x{CLOUDSDK_PYTHON=/usr/bin/python2.7 #{gsutil} cp gs://#{bucket}/#{filename} -} - secret = shell_out("CLOUDSDK_PYTHON=/usr/bin/python2.7 #{gsutil} cp gs://#{bucket}/#{filename} -").stdout.str + cmd = if File.exist?("/usr/bin/python2.7") + %Q{CLOUDSDK_PYTHON=/usr/bin/python2.7 #{gsutil} cp gs://#{bucket}/#{filename} -} else - # secret = %x{#{gsutil} cp gs://#{bucket}/#{filename} -} - secret = shell_out("#{gsutil} cp gs://#{bucket}/#{filename} -").stdout.str + %Q{#{gsutil} cp gs://#{bucket}/#{filename} -} + end + Chef::Log.info(cmd) + resp = shell_out(cmd) + if resp.status.exitstatus != 0 + raise "\nDeploy secret fetch failed with exit code #{resp.status.exitstatus.to_s}: #{resp.stderr}. Command was:\n#{cmd}" end + secret = resp.stdout break if !secret.nil? and !secret.empty? } if secret.nil? or secret.empty? diff --git a/cookbooks/mu-tools/metadata.rb b/cookbooks/mu-tools/metadata.rb index 4ecc88fa0..1bf866816 100644 --- a/cookbooks/mu-tools/metadata.rb +++ b/cookbooks/mu-tools/metadata.rb @@ -7,14 +7,14 @@ source_url 'https://github.com/cloudamatic/mu' issues_url 'https://github.com/cloudamatic/mu/issues' chef_version '>= 14.0' if respond_to?(:chef_version) -version '1.0.4' +version '1.1.0' %w( amazon centos redhat windows ).each do |os| supports os end depends "oracle-instantclient", '~> 1.1.0' -depends "nagios" +depends "mu-nagios" depends "database", '~> 6.1.1' depends "postgresql", '~> 7.1.0' depends "mu-utility" @@ -26,3 +26,6 @@ depends "yum-epel", '~> 3.2.0' depends "mu-firewall" depends "mu-activedirectory" +depends "chocolatey" +depends "firewall" +depends 'selinux', '~> 3.0.0' diff --git a/cookbooks/mu-tools/recipes/apply_security.rb b/cookbooks/mu-tools/recipes/apply_security.rb index 301192b2e..33157c1ab 100644 --- a/cookbooks/mu-tools/recipes/apply_security.rb +++ b/cookbooks/mu-tools/recipes/apply_security.rb @@ -145,7 +145,7 @@ end - if node.normal.root_login_disabled + if node['root_login_disabled'] #some code end @@ -333,10 +333,9 @@ device node['application_attributes']['home']['mount_device'] size node['application_attributes']['home']['volume_size_gb'] preserve_data true - not_if "awk '{print $2}' < /etc/mtab | grep '^/home$'" end - Chef::Log.info("Value of login_disabled is #{node.normal.root_login_disabled}") + Chef::Log.info("Value of login_disabled is #{node['root_login_disabled']}") ruby_block "do a bunch of weird stuff" do # ~FC014 block do diff --git a/cookbooks/mu-tools/recipes/eks.rb b/cookbooks/mu-tools/recipes/eks.rb index 4ad6aabec..fe5c879af 100644 --- a/cookbooks/mu-tools/recipes/eks.rb +++ b/cookbooks/mu-tools/recipes/eks.rb @@ -93,7 +93,7 @@ source "https://s3-us-west-2.amazonaws.com/amazon-eks/1.10.3/2018-06-05/eks-2017-11-01.normal.json" end - execute "aws configure add-model --service-model file://root/.aws/eks/eks-2017-11-01.normal.json --service-name eks" + execute "aws configure add-model --service-model file:///root/.aws/eks/eks-2017-11-01.normal.json --service-name eks" execute "systemctl daemon-reload" do action :nothing @@ -112,17 +112,40 @@ notifies :restart, "service[kubelet]", :delayed end + file "/etc/systemd/system/kubelet.service.d/10-kubelet-args.conf" do + content "[Service] +Environment='KUBELET_ARGS=--node-ip=#{get_aws_metadata("meta-data/local-ipv4")} --pod-infra-container-image=602401143452.dkr.ecr.#{region}.amazonaws.com/eks/pause-amd64:3.1'" + notifies :run, "execute[systemctl daemon-reload]", :immediately + notifies :restart, "service[kubelet]", :delayed + end + + template "/etc/kubernetes/kubelet/kubelet-config.json" do + source "kubelet-config.json.erb" + variables( + :dns => get_first_nameserver(), + ) + notifies :restart, "service[kubelet]", :delayed + end + + file "/etc/systemd/system/kubelet.service.d/30-kubelet-extra-args.conf" do + content "[Service] +Environment='KUBELET_EXTRA_ARGS=$KUBELET_EXTRA_ARGS' +" + notifies :restart, "service[kubelet]", :delayed + notifies :run, "execute[systemctl daemon-reload]", :immediately + end + directory "/root/.kube" remote_file "/usr/bin/aws-iam-authenticator" do - source "https://amazon-eks.s3-us-west-2.amazonaws.com/1.10.3/2018-07-26/bin/linux/amd64/aws-iam-authenticator" + source "https://amazon-eks.s3-us-west-2.amazonaws.com/1.12.7/2019-03-27/bin/linux/amd64/aws-iam-authenticator" mode 0755 not_if "test -f /usr/bin/aws-iam-authenticator" end ["/var/lib/kubelet/kubeconfig", "/root/.kube/config"].each { |kubecfg| template kubecfg do - source "kubeconfig.erb" + source "kubeconfig-eks.erb" variables( :endpoint => endpoint, :cluster => cluster, diff --git a/cookbooks/mu-tools/recipes/gcloud.rb b/cookbooks/mu-tools/recipes/gcloud.rb index da691b994..bbde3f0bb 100644 --- a/cookbooks/mu-tools/recipes/gcloud.rb +++ b/cookbooks/mu-tools/recipes/gcloud.rb @@ -28,49 +28,31 @@ end package "google-cloud-sdk" elsif node['platform_version'].to_i == 6 - rpm_package "IUS" do - source "https://#{node['platform']}#{node['platform_version'].to_i}.iuscommunity.org/ius-release.rpm" - end - package ["python27", "python27-libs"] + version = "267.0.0" remote_file "#{Chef::Config[:file_cache_path]}/gcloud-cli.sh" do source "https://sdk.cloud.google.com" action :nothing end remote_file "#{Chef::Config[:file_cache_path]}/gcloud-cli.tar.gz" do - source "https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-167.0.0-linux-x86_64.tar.gz" + source "https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-sdk-#{version}-linux-x86_64.tar.gz" action :nothing end bash "install gcloud-cli" do cwd "/opt" code <<-EOH - # This broken-arsed package set install themselves in the wrong prefix - # for some reason, but if you do it manually they land in the right - # place. Whatever, just symlink it. - filelist=`rpm -qa | grep ^python27- | xargs rpm -ql` - for d in $filelist;do - if [ -d "$d" ];then - rightpath=`echo $d | sed 's/^\\/opt\\/rh\\/python27\\/root//'` - if [ "$rightpath" != "$d" -a ! -e "$rightpath" ];then - echo $rightpath | grep -v / - mkdir -p "$rightpath" - fi - fi - done - for f in $filelist;do - if [ -f "$f" ];then - rightpath=`echo $f | sed 's/^\\/opt\\/rh\\/python27\\/root//'` - if [ "$rightpath" != "$f" -a ! -e "$rightpath" ];then - ln -s "$f" "$rightpath" - fi - fi - done tar -xzf #{Chef::Config[:file_cache_path]}/gcloud-cli.tar.gz - CLOUDSDK_PYTHON=/usr/bin/python2.7 ./google-cloud-sdk/install.sh -q -# CLOUDSDK_PYTHON=/usr/bin/python2.7 sh #{Chef::Config[:file_cache_path]}/gcloud-cli.sh --install-dir=/opt --disable-prompts + if [ -f /opt/rh/python27/root/usr/bin/python ];then + if [ ! -f /etc/ld.so.conf.d/python27.conf ];then + echo "/opt/rh/python27/root/usr/lib64" > /etc/ld.so.conf.d/python27.conf + echo "/opt/rh/python27/root/usr/lib" >> /etc/ld.so.conf.d/python27.conf + /sbin/ldconfig + fi + fi + CLOUDSDK_PYTHON="`/bin/rpm -ql muthon python27-python | grep '/bin/python$'`" ./google-cloud-sdk/install.sh -q EOH notifies :create, "remote_file[#{Chef::Config[:file_cache_path]}/gcloud-cli.sh]", :before notifies :create, "remote_file[#{Chef::Config[:file_cache_path]}/gcloud-cli.tar.gz]", :before - not_if { ::File.exist?("/opt/google-cloud-sdk/bin/gcloud") } + not_if "/opt/google-cloud-sdk/bin/gcloud version | grep 'Google Cloud SDK #{version}'" end link "/etc/bash_completion.d/gcloud" do to "/opt/google-cloud-sdk/completion.bash.inc" @@ -79,7 +61,7 @@ to "/opt/google-cloud-sdk/path.bash.inc" end file "/etc/profile.d/gcloud_python.sh" do - content "export CLOUDSDK_PYTHON=/usr/bin/python2.7\n" + content "export CLOUDSDK_PYTHON=\"`/bin/rpm -ql muthon | grep '/bin/python$'`\"\n" mode 0644 end end diff --git a/cookbooks/mu-tools/recipes/nagios.rb b/cookbooks/mu-tools/recipes/nagios.rb index b6409df4d..654e7181d 100644 --- a/cookbooks/mu-tools/recipes/nagios.rb +++ b/cookbooks/mu-tools/recipes/nagios.rb @@ -16,4 +16,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -include_recipe "nagios" +include_recipe "mu-nagios" diff --git a/cookbooks/mu-tools/recipes/nrpe.rb b/cookbooks/mu-tools/recipes/nrpe.rb index 99b7e697b..69dace8d1 100644 --- a/cookbooks/mu-tools/recipes/nrpe.rb +++ b/cookbooks/mu-tools/recipes/nrpe.rb @@ -82,7 +82,12 @@ service "nrpe" do action [:enable, :start] end - + + # Workaround for Amazon Linux/Chef 14 problem in nrpe cookbook + # https://github.com/sous-chefs/nrpe/issues/96 + node.normal['nrpe']['plugin_dir'] = "/usr/lib64/nagios/plugins" + node.save + nrpe_check "check_disk" do command "#{node['nrpe']['plugin_dir']}/check_disk" warning_condition '15%' diff --git a/cookbooks/mu-tools/recipes/rsyslog.rb b/cookbooks/mu-tools/recipes/rsyslog.rb index c7760d546..44f318e98 100644 --- a/cookbooks/mu-tools/recipes/rsyslog.rb +++ b/cookbooks/mu-tools/recipes/rsyslog.rb @@ -24,6 +24,7 @@ execute "chcon -R -h -t var_log_t /Mu_Logs" do action :nothing only_if { ::Dir.exist?("/Mu_Logs") } + not_if "/sbin/getenforce | grep -cim1 disabled" end service "rsyslog" do action [:enable, :start] diff --git a/cookbooks/mu-tools/recipes/selinux.rb b/cookbooks/mu-tools/recipes/selinux.rb new file mode 100644 index 000000000..c7518d8f8 --- /dev/null +++ b/cookbooks/mu-tools/recipes/selinux.rb @@ -0,0 +1,19 @@ +# +# Cookbook:: mu-tools +# Recipe:: selinux +# +# Copyright:: 2019, The Authors, All Rights Reserved. + +if !node['application_attributes']['skip_recipes'].include?('selinux') + + selinux_state "SELinux Enforcing" do + action :enforcing + notifies :request_reboot, 'reboot[now]', :immediately + end + + reboot 'now' do + action :nothing + reason 'Must reboot to enable SELinux.' + end + +end diff --git a/cookbooks/mu-tools/recipes/set_mu_hostname.rb b/cookbooks/mu-tools/recipes/set_mu_hostname.rb index cf1291d7e..bf17ede82 100644 --- a/cookbooks/mu-tools/recipes/set_mu_hostname.rb +++ b/cookbooks/mu-tools/recipes/set_mu_hostname.rb @@ -60,6 +60,14 @@ file "/etc/hostname" do content $hostname end + elsif node['platform'] == "amazon" + file "/etc/hostname" do + content $hostname + end + execute "set hostname" do + command "hostname #{$hostname}" + not_if "test \"`hostname`\" = \"#{$hostname}\" " + end else execute "set hostname" do command "hostname #{$hostname}" diff --git a/cookbooks/mu-tools/recipes/split_var_partitions.rb b/cookbooks/mu-tools/recipes/split_var_partitions.rb index cafde7f04..b747ec755 100644 --- a/cookbooks/mu-tools/recipes/split_var_partitions.rb +++ b/cookbooks/mu-tools/recipes/split_var_partitions.rb @@ -23,7 +23,6 @@ # make it part of your regular build process. if !node['application_attributes']['skip_recipes'].include?('split_var_partitions') - log "*************** "+node['platform'] case node['platform'] when "redhat", "rhel", "centos", "amazon" diff --git a/cookbooks/mu-tools/recipes/windows-client.rb b/cookbooks/mu-tools/recipes/windows-client.rb index 71f96e8c0..e5fa208cd 100644 --- a/cookbooks/mu-tools/recipes/windows-client.rb +++ b/cookbooks/mu-tools/recipes/windows-client.rb @@ -19,16 +19,148 @@ case node['platform'] when "windows" include_recipe 'chef-vault' - ::Chef::Recipe.send(:include, Chef::Mixin::PowershellOut) + + windows_vault = chef_vault_item node['windows_auth_vault'], node['windows_auth_item'] + + sshd_user = 'SYSTEM' #windows_vault[node['windows_sshd_username_field']] + + sshd_password = windows_vault[node['windows_sshd_password_field']] + + windows_version = node['platform_version'].to_i + + public_keys = Array.new + + if windows_version == 10 + Chef::Log.info "version #{windows_version}, using openssh" + + include_recipe 'chocolatey' + + openssh_path = 'C:\Program Files\OpenSSH-Win64' + + ssh_program_data = "#{ENV['ProgramData']}/ssh" + + ssh_dir = "C:/Users/Administrator/.ssh" + + authorized_keys = "#{ssh_dir}/authorized_keys" + + public_key = node['deployment']['ssh_public_key'] + + files = [] + + packages = %w(openssh ruby) + + chocolatey_package packages + + windows_path 'Add OpenSSH to path' do + path openssh_path + action :add + end + + powershell_script 'Install SSH' do + code '.\install-sshd.ps1' + cwd openssh_path + end + +# firewall 'default' do +# ipv6_enabled node['firewall']['ipv6_enabled'] +# action :disable +# end +# +# firewall_rule 'allow ssh' do +# port 22 +# command :allow +# description 'OpenSSH Server (sshd)' +# end +# +# firewall_rule 'allow RDP' do +# port 3389 +# command :allow +# end +# +# firewall_rule 'allow winrm' do +# port 5989 +# command :allow +# end + + directory 'create ssh ProgramData' do + path ssh_program_data + owner sshd_user + rights :full_control, sshd_user + rights :full_control, 'Administrator' + notifies :run, 'powershell_script[Generate Host Key]', :immediately + end + + powershell_script 'Generate Host Key' do + code '.\ssh-keygen.exe -A' + cwd openssh_path + action :nothing + notifies :create, "template[#{ssh_program_data}/sshd_config]", :immediately + end + + template "#{ssh_program_data}/sshd_config" do + action :nothing + owner sshd_user + source "sshd_config.erb" + mode '0600' + cookbook "mu-tools" + notifies :run, 'ruby[find files to change ownership of]', :immediately + end + + directory "set file ownership" do + action :nothing + path ssh_program_data + owner sshd_user + mode '0600' + rights :full_control, sshd_user + deny_rights :full_control, 'Administrator' + end + + windows_service 'sshd' do + action :nothing #[ :enable, :start ] + end + + group 'sshusers' do + members [sshd_user, 'Administrator'] + end + + ruby 'find files to change ownership of' do + action :nothing + code <<-EOH + files = Dir.entries ssh_program_data + puts files + EOH + end + + log 'files in ssh' do + message files.join + level :info + end + + files.each do |file| + file "#{ssh_program_data}#{file}" do + owner sshd_user + deny_rights :full_control, 'Administrator' + end + end + + directory "create Admin's .ssh directory" do + path ssh_dir + recursive true + owner sshd_user + end + + file authorized_keys do + owner 'Administrator' + content public_key + end + + else + ::Chef::Recipe.send(:include, Chef::Mixin::PowershellOut) # remote_file "cygwin-x86_64.exe" do # path "#{Chef::Config[:file_cache_path]}/cygwin-x86_64.exe" # source "http://cygwin.com/setup-x86_64.exe" -# XXX guard with a version check -# end - -# XXX keep a local cache of packages... really our own damn mirror - cygwindir = "c:/bin/cygwin" + cygwindir = "c:/bin/cygwin" # pkgs = ["bash", "mintty", "vim", "curl", "openssl", "wget", "lynx", "openssh"] # powershell_script "install Cygwin" do @@ -38,7 +170,7 @@ # not_if { ::File.exist?("#{cygwindir}/Cygwin.bat") } # end - # Be prepared to reinit installs that are missing key utilities + # Be prepared to reinit installs that are missing key utilities # file "#{cygwindir}/etc/setup/installed.db" do # action :delete # not_if { ::File.exist?("#{cygwindir}/bin/cygcheck.exe") } @@ -52,138 +184,135 @@ # end # } - reboot "Cygwin LSA" do - action :nothing - reason "Enabling Cygwin LSA support" - end - - powershell_script "Configuring Cygwin LSA support" do - code <<-EOH - Invoke-Expression '& #{cygwindir}/bin/bash.exe --login -c "echo yes | /bin/cyglsa-config"' - EOH - not_if { - lsa_found = false - if registry_key_exists?("HKLM\\SYSTEM\\CurrentControlSet\\Control\\Lsa") - registry_get_values("HKLM\\SYSTEM\\CurrentControlSet\\Control\\Lsa").each { |val| - if val[:name] == "Authentication Packages" - lsa_found = true if val[:data].grep(/cyglsa64\.dll/) - break - end - } - end - lsa_found - } - notifies :reboot_now, "reboot[Cygwin LSA]", :immediately - end + reboot "Cygwin LSA" do + action :nothing + reason "Enabling Cygwin LSA support" + end - windows_vault = chef_vault_item(node['windows_auth_vault'], node['windows_auth_item']) - sshd_user = windows_vault[node['windows_sshd_username_field']] - sshd_password = windows_vault[node['windows_sshd_password_field']] - powershell_script "enable Cygwin sshd" do - code <<-EOH - Invoke-Expression -Debug '& #{cygwindir}/bin/bash.exe --login -c "ssh-host-config -y -c ntsec -w ''#{sshd_password}'' -u #{sshd_user}"' - Invoke-Expression -Debug '& #{cygwindir}/bin/bash.exe --login -c "sed -i.bak ''s/#.*StrictModes.*yes/StrictModes no/'' /etc/sshd_config"' - Invoke-Expression -Debug '& #{cygwindir}/bin/bash.exe --login -c "sed -i.bak ''s/#.*PasswordAuthentication.*yes/PasswordAuthentication no/'' /etc/sshd_config"' - Invoke-Expression -Debug '& #{cygwindir}/bin/bash --login -c "chown #{sshd_user} /var/empty /var/log/sshd.log /etc/ssh*; chmod 755 /var/empty"' - EOH - sensitive true - not_if %Q{Get-Service "sshd"} - end - powershell_script "set unix-style Cygwin sshd permissions" do - code <<-EOH - if((Get-WmiObject win32_computersystem).partofdomain){ - Invoke-Expression -Debug '& #{cygwindir}/bin/bash --login -c "mkpasswd -d > /etc/passwd"' - Invoke-Expression -Debug '& #{cygwindir}/bin/bash --login -c "mkgroup -l -d > /etc/group"' - } else { - Invoke-Expression -Debug '& #{cygwindir}/bin/bash --login -c "mkpasswd -l > /etc/passwd"' - Invoke-Expression -Debug '& #{cygwindir}/bin/bash --login -c "mkgroup -l > /etc/group"' + powershell_script "Configuring Cygwin LSA support" do + code <<-EOH + Invoke-Expression '& #{cygwindir}/bin/bash.exe --login -c "echo yes | /bin/cyglsa-config"' + EOH + not_if { + lsa_found = false + if registry_key_exists?("HKLM\\SYSTEM\\CurrentControlSet\\Control\\Lsa") + registry_get_values("HKLM\\SYSTEM\\CurrentControlSet\\Control\\Lsa").each { |val| + if val[:name] == "Authentication Packages" + lsa_found = true if val[:data].grep(/cyglsa64\.dll/) + break + end + } + end + lsa_found } - Invoke-Expression -Debug '& #{cygwindir}/bin/bash --login -c "chown #{sshd_user} /var/empty /var/log/sshd.log /etc/ssh*; chmod 755 /var/empty"' - EOH - end + notifies :reboot_now, "reboot[Cygwin LSA]", :immediately + end - include_recipe 'mu-activedirectory' + powershell_script "enable Cygwin sshd" do + code <<-EOH + Invoke-Expression -Debug '& #{cygwindir}/bin/bash.exe --login -c "ssh-host-config -y -c ntsec -w ''#{sshd_password}'' -u #{sshd_user}"' + Invoke-Expression -Debug '& #{cygwindir}/bin/bash.exe --login -c "sed -i.bak ''s/#.*StrictModes.*yes/StrictModes no/'' /etc/sshd_config"' + Invoke-Expression -Debug '& #{cygwindir}/bin/bash.exe --login -c "sed -i.bak ''s/#.*PasswordAuthentication.*yes/PasswordAuthentication no/'' /etc/sshd_config"' + Invoke-Expression -Debug '& #{cygwindir}/bin/bash --login -c "chown #{sshd_user} /var/empty /var/log/sshd.log /etc/ssh*; chmod 755 /var/empty"' + EOH + sensitive true + not_if %Q{Get-Service "sshd"} + end + powershell_script "set unix-style Cygwin sshd permissions" do + code <<-EOH + if((Get-WmiObject win32_computersystem).partofdomain){ + Invoke-Expression -Debug '& #{cygwindir}/bin/bash --login -c "mkpasswd -d > /etc/passwd"' + Invoke-Expression -Debug '& #{cygwindir}/bin/bash --login -c "mkgroup -l -d > /etc/group"' + } else { + Invoke-Expression -Debug '& #{cygwindir}/bin/bash --login -c "mkpasswd -l > /etc/passwd"' + Invoke-Expression -Debug '& #{cygwindir}/bin/bash --login -c "mkgroup -l > /etc/group"' + } + Invoke-Expression -Debug '& #{cygwindir}/bin/bash --login -c "chown #{sshd_user} /var/empty /var/log/sshd.log /etc/ssh*; chmod 755 /var/empty"' + EOH + end - ::Chef::Recipe.send(:include, Chef::Mixin::PowershellOut) + include_recipe 'mu-activedirectory' - template "c:/bin/cygwin/etc/sshd_config" do - source "sshd_config.erb" - mode 0644 - cookbook "mu-tools" - ignore_failure true - end + ::Chef::Recipe.send(:include, Chef::Mixin::PowershellOut) - ec2config_user= windows_vault[node['windows_ec2config_username_field']] - ec2config_password = windows_vault[node['windows_ec2config_password_field']] - login_dom = "." + template "c:/bin/cygwin/etc/sshd_config" do + source "sshd_config.erb" + mode 0644 + cookbook "mu-tools" + ignore_failure true + end - if in_domain? + ec2config_user= windows_vault[node['windows_ec2config_username_field']] + ec2config_password = windows_vault[node['windows_ec2config_password_field']] + login_dom = "." - ad_vault = chef_vault_item(node['ad']['domain_admin_vault'], node['ad']['domain_admin_item']) - login_dom = node['ad']['netbios_name'] + if in_domain? - windows_users node['ad']['computer_name'] do - username ad_vault[node['ad']['domain_admin_username_field']] - password ad_vault[node['ad']['domain_admin_password_field']] - domain_name node['ad']['domain_name'] - netbios_name node['ad']['netbios_name'] - dc_ips node['ad']['dc_ips'] - ssh_user sshd_user - ssh_password sshd_password - ec2config_user ec2config_user - ec2config_password ec2config_password - end + ad_vault = chef_vault_item(node['ad']['domain_admin_vault'], node['ad']['domain_admin_item']) + login_dom = node['ad']['netbios_name'] - aws_windows "ec2" do - username ec2config_user - service_username "#{node['ad']['netbios_name']}\\#{ec2config_user}" - password ec2config_password - end + windows_users node['ad']['computer_name'] do + username ad_vault[node['ad']['domain_admin_username_field']] + password ad_vault[node['ad']['domain_admin_password_field']] + domain_name node['ad']['domain_name'] + netbios_name node['ad']['netbios_name'] + dc_ips node['ad']['dc_ips'] + ssh_user sshd_user + ssh_password sshd_password + ec2config_user ec2config_user + ec2config_password ec2config_password + end - scheduled_tasks "tasks" do - username ad_vault[node['ad']['domain_admin_username_field']] - password ad_vault[node['ad']['domain_admin_password_field']] - end + aws_windows "ec2" do + username ec2config_user + service_username "#{node['ad']['netbios_name']}\\#{ec2config_user}" + password ec2config_password + end - sshd_service "sshd" do - service_username "#{node['ad']['netbios_name']}\\#{sshd_user}" - username sshd_user - password sshd_password - end + scheduled_tasks "tasks" do + username ad_vault[node['ad']['domain_admin_username_field']] + password ad_vault[node['ad']['domain_admin_password_field']] + end - begin - resources('service[sshd]') - rescue Chef::Exceptions::ResourceNotFound - service "sshd" do - action [:enable, :start] - sensitive true + sshd_service "sshd" do + service_username "#{node['ad']['netbios_name']}\\#{sshd_user}" + username sshd_user + password sshd_password end - end - else - windows_users node['hostname'] do - username node['windows_admin_username'] - password windows_vault[node['windows_auth_password_field']] - ssh_user sshd_user - ssh_password sshd_password - ec2config_user ec2config_user - ec2config_password ec2config_password - end - aws_windows "ec2" do - username ec2config_user - service_username ".\\#{ec2config_user}" - password ec2config_password - end + begin + resources('service[sshd]') + escue Chef::Exceptions::ResourceNotFound + service "sshd" do + action [:enable, :start] + sensitive true + end + end + else + windows_users node['hostname'] do + username node['windows_admin_username'] + password windows_vault[node['windows_auth_password_field']] + ssh_user sshd_user + ssh_password sshd_password + ec2config_user ec2config_user + ec2config_password ec2config_password + end - scheduled_tasks "tasks" do - username node['windows_admin_username'] - password windows_vault[node['windows_auth_password_field']] - end + aws_windows "ec2" do + username ec2config_user + service_username ".\\#{ec2config_user}" + password ec2config_password + end - sshd_service "sshd" do - username sshd_user - service_username ".\\#{sshd_user}" - password sshd_password + scheduled_tasks "tasks" do + username node['windows_admin_username'] + password windows_vault[node['windows_auth_password_field']] + end + + sshd_service "sshd" do + username sshd_user + service_username ".\\#{sshd_user}" + password sshd_password end begin resources('service[sshd]') @@ -195,8 +324,13 @@ end end end + end else Chef::Log.info("mu-tools::windows-client: Unsupported platform #{node['platform']}") end end +# Copyright:: Copyright (c) 2014 eGlobalTech, Inc., all rights reserved +# +# Cookbook Name:: mu-tools +# Recipe:: windows-client diff --git a/cookbooks/mu-tools/resources/disk.rb b/cookbooks/mu-tools/resources/disk.rb index 89c228111..9227fdc6d 100644 --- a/cookbooks/mu-tools/resources/disk.rb +++ b/cookbooks/mu-tools/resources/disk.rb @@ -1,6 +1,7 @@ property :mountpoint, String, name_property: true property :device, String, required: true +property :delete_on_termination, :kind_of => [TrueClass, FalseClass], default: true property :preserve_data, :kind_of => [TrueClass, FalseClass], :required => false, :default => false property :reboot_after_create, :kind_of => [TrueClass, FalseClass], :required => false, :default => false property :size, Integer, default: 8 @@ -22,7 +23,8 @@ request "add_volume" passparams( :dev => devicename, - :size => new_resource.size + :size => new_resource.size, + :delete_on_termination => new_resource.delete_on_termination ) not_if { ::File.exist?(device) } end diff --git a/cookbooks/mu-tools/templates/amazon/sshd_config.erb b/cookbooks/mu-tools/templates/amazon/sshd_config.erb index 6800f4e86..ac6fe8d68 100644 --- a/cookbooks/mu-tools/templates/amazon/sshd_config.erb +++ b/cookbooks/mu-tools/templates/amazon/sshd_config.erb @@ -165,4 +165,4 @@ UseDNS no # CAP Mod, restrict ciphers Ciphers aes128-ctr,aes192-ctr,aes256-ctr -AllowUsers root +AllowUsers ec2-user root diff --git a/cookbooks/mu-tools/templates/default/kubeconfig.erb b/cookbooks/mu-tools/templates/default/kubeconfig-eks.erb similarity index 90% rename from cookbooks/mu-tools/templates/default/kubeconfig.erb rename to cookbooks/mu-tools/templates/default/kubeconfig-eks.erb index ac3639c1c..6a592259e 100644 --- a/cookbooks/mu-tools/templates/default/kubeconfig.erb +++ b/cookbooks/mu-tools/templates/default/kubeconfig-eks.erb @@ -3,10 +3,10 @@ clusters: - cluster: server: <%= @endpoint %> certificate-authority-data: <%= @cacert %> - name: kubernetes + name: <%= @cluster %> contexts: - context: - cluster: kubernetes + cluster: <%= @cluster %> user: aws name: aws current-context: aws diff --git a/cookbooks/mu-tools/templates/default/kubeconfig-gke.erb b/cookbooks/mu-tools/templates/default/kubeconfig-gke.erb new file mode 100644 index 000000000..db44cc7db --- /dev/null +++ b/cookbooks/mu-tools/templates/default/kubeconfig-gke.erb @@ -0,0 +1,27 @@ +apiVersion: v1 +clusters: +- cluster: + server: <%= @endpoint %> + certificate-authority-data: <%= @cacert %> + name: <%= @cluster %> +kind: Config +preferences: {} +contexts: +- context: + cluster: <%= @cluster %> + user: client + name: client +<% if @username and @password %>- context: + cluster: <%= @cluster %> + user: <%= @username %> + name: <%= @username %> +current-context: <%= @username %><% else %>current-context: client<% end %> +users: +- name: client + user: + client-certificate-data: <%= @clientcert %> + client-key-data: <%= @clientkey %> +<% if @username and @password %>- name: <%= @username %> + user: + username: <%= @username %> + password: <%= @password %><% end %> diff --git a/cookbooks/mu-tools/templates/default/kubelet-config.json.erb b/cookbooks/mu-tools/templates/default/kubelet-config.json.erb new file mode 100644 index 000000000..7dfaa59f0 --- /dev/null +++ b/cookbooks/mu-tools/templates/default/kubelet-config.json.erb @@ -0,0 +1,35 @@ +{ + "kind": "KubeletConfiguration", + "apiVersion": "kubelet.config.k8s.io/v1beta1", + "address": "0.0.0.0", + "clusterDNS": "<%= @dns %>", + "authentication": { + "anonymous": { + "enabled": false + }, + "webhook": { + "cacheTTL": "2m0s", + "enabled": true + }, + "x509": { + "clientCAFile": "/etc/kubernetes/pki/ca.crt" + } + }, + "authorization": { + "mode": "Webhook", + "webhook": { + "cacheAuthorizedTTL": "5m0s", + "cacheUnauthorizedTTL": "30s" + } + }, + "clusterDomain": "cluster.local", + "hairpinMode": "hairpin-veth", + "cgroupDriver": "cgroupfs", + "cgroupRoot": "/", + "featureGates": { + "RotateKubeletServerCertificate": true + }, + "serializeImagePulls": false, + "serverTLSBootstrap": true, + "configMapAndSecretChangeDetectionStrategy": "Cache" +} diff --git a/cookbooks/mu-tools/templates/windows-10/sshd_config.erb b/cookbooks/mu-tools/templates/windows-10/sshd_config.erb new file mode 100644 index 000000000..06816faf9 --- /dev/null +++ b/cookbooks/mu-tools/templates/windows-10/sshd_config.erb @@ -0,0 +1,137 @@ +# $OpenBSD: sshd_config,v 1.99 2016/07/11 03:19:44 tedu Exp $ + +# This is the sshd server system-wide configuration file. See +# sshd_config(5) for more information. + +# This sshd was compiled with PATH=/bin:/usr/sbin:/sbin:/usr/bin + +# The strategy used for options in the default sshd_config shipped with +# OpenSSH is to specify options with their default value where +# possible, but leave them commented. Uncommented options override the +# default value. + +#Port 22 +#AddressFamily any +#ListenAddress 0.0.0.0 +#ListenAddress :: + +# The default requires explicit activation of protocol 1 +#Protocol 2 + +# HostKey for protocol version 1 +#HostKey C:\ProgramData\ssh\ssh_host_key +# HostKeys for protocol version 2 +HostKey C:\ProgramData\ssh\ssh_host_rsa_key +HostKey C:\ProgramData\ssh\ssh_host_dsa_key +HostKey C:\ProgramData\ssh\ssh_host_ecdsa_key +HostKey C:\ProgramData\ssh\ssh_host_ed25519_key + +# Lifetime and size of ephemeral version 1 server key +#KeyRegenerationInterval 1h +#ServerKeyBits 1024 + +# Ciphers and keying +#RekeyLimit default none + +Ciphers aes256-ctr +KexAlgorithms diffie-hellman-group-exchange-sha256 +MACs hmac-sha2-256 + +# Logging +#SyslogFacility AUTH +#LogLevel ERROR + +# Authentication: + +#LoginGraceTime 2m +#PermitRootLogin prohibit-password +StrictModes no +#MaxAuthTries 6 +#MaxSessions 10 + +#RSAAuthentication yes +#PubkeyAuthentication yes + +# The default is to check both .ssh/authorized_keys and .ssh/authorized_keys2 +# but this is overridden so installations will only check .ssh/authorized_keys +AuthorizedKeysFile .ssh/authorized_keys + +#AuthorizedPrincipalsFile none + +#AuthorizedKeysCommand none +#AuthorizedKeysCommandUser nobody + +# For this to work you will also need host keys in /etc/ssh_known_hosts +#RhostsRSAAuthentication no +# similar for protocol version 2 +#HostbasedAuthentication no +# Change to yes if you don't trust ~/.ssh/known_hosts for +# RhostsRSAAuthentication and HostbasedAuthentication +#IgnoreUserKnownHosts no +# Don't read the user's ~/.rhosts and ~/.shosts files +#IgnoreRhosts yes + +# To disable tunneled clear text passwords, change to no here! +PasswordAuthentication no +#PermitEmptyPasswords no + +# Change to no to disable s/key passwords +#ChallengeResponseAuthentication yes + +# Kerberos options +#KerberosAuthentication no +#KerberosOrLocalPasswd yes +#KerberosTicketCleanup yes +#KerberosGetAFSToken no + +# GSSAPI options +#GSSAPIAuthentication no +#GSSAPICleanupCredentials yes + +# Set this to 'yes' to enable PAM authentication, account processing, +# and session processing. If this is enabled, PAM authentication will +# be allowed through the ChallengeResponseAuthentication and +# PasswordAuthentication. Depending on your PAM configuration, +# PAM authentication via ChallengeResponseAuthentication may bypass +# the setting of "PermitRootLogin without-password". +# If you just want the PAM account and session checks to run without +# PAM authentication, then enable this but set PasswordAuthentication +# and ChallengeResponseAuthentication to 'no'. +#UsePAM no + +#AllowAgentForwarding yes +#AllowTcpForwarding yes +#GatewayPorts no +#X11Forwarding no +#X11DisplayOffset 10 +#X11UseLocalhost yes +#PermitTTY yes +#PrintMotd yes +#PrintLastLog yes +#TCPKeepAlive yes +#UseLogin no +#PermitUserEnvironment no +#Compression delayed +#ClientAliveInterval 0 +#ClientAliveCountMax 3 +#UseDNS no +#PidFile /var/run/sshd.pid +#MaxStartups 10:30:100 +#PermitTunnel no +#ChrootDirectory none +#VersionAddendum none + +# no default banner path +#Banner none + +# override default of no subsystems +Subsystem sftp /usr/sbin/sftp-server + +# Example of overriding settings on a per-user basis +#Match User anoncvs +# X11Forwarding no +# AllowTcpForwarding no +# PermitTTY no +# ForceCommand cvs server + +AllowGroups Administrators sshusers diff --git a/cookbooks/mu-utility/recipes/nat.rb b/cookbooks/mu-utility/recipes/nat.rb index 0458f2711..fdb317c6f 100644 --- a/cookbooks/mu-utility/recipes/nat.rb +++ b/cookbooks/mu-utility/recipes/nat.rb @@ -56,6 +56,10 @@ raw "-A FORWARD -m state --state RELATED,ESTABLISHED -j ACCEPT" position 97 end + firewall_rule "inbound from NAT network" do + raw "-A INPUT -s #{$ip_block} -j ACCEPT" + position 98 + end firewall_rule "NAT forwarding" do raw "-A FORWARD -s #{$ip_block} -j ACCEPT" position 98 diff --git a/cookbooks/nagios/.foodcritic b/cookbooks/nagios/.foodcritic deleted file mode 100644 index 8ea2f3524..000000000 --- a/cookbooks/nagios/.foodcritic +++ /dev/null @@ -1 +0,0 @@ -~FC003 ~FC015 ~FC023 diff --git a/cookbooks/nagios/.gitignore b/cookbooks/nagios/.gitignore deleted file mode 100644 index abdff6a48..000000000 --- a/cookbooks/nagios/.gitignore +++ /dev/null @@ -1,51 +0,0 @@ -*.rbc -.config -coverage -InstalledFiles -lib/bundler/man -pkg -rdoc -spec/reports -test/tmp -test/version_tmp -tmp -_Store -*~ -*# -.#* -\#*# -.*.sw[a-z] -*.un~ -*.tmp -*.bk -*.bkup - -# ruby/bundler files -.ruby-version -.ruby-gemset -.rvmrc -Gemfile.lock -.bundle -*.gem - -# YARD artifacts -.yardoc -_yardoc -doc/ -.idea - -# chef stuff -Berksfile.lock -.kitchen -.kitchen.local.yml -vendor/ -.coverage/ -.zero-knife.rb -Policyfile.lock.json -Cheffile.lock -.librarian/ - -# vagrant stuff -.vagrant/ -.vagrant.d/ -.kitchen/ diff --git a/cookbooks/nagios/.kitchen.dokken.yml b/cookbooks/nagios/.kitchen.dokken.yml deleted file mode 100644 index 1d612a157..000000000 --- a/cookbooks/nagios/.kitchen.dokken.yml +++ /dev/null @@ -1,121 +0,0 @@ -driver: - name: dokken - privileged: true # because Docker and SystemD/Upstart - chef_version: current - -transport: - name: dokken - -provisioner: - name: dokken - deprecations_as_errors: true - -verifier: - name: inspec - -platforms: -- name: debian-7 - driver: - image: debian:7 - pid_one_command: /sbin/init - intermediate_instructions: - - RUN /usr/bin/apt-get update - - RUN /usr/bin/apt-get install apt-transport-https lsb-release procps net-tools -y - -- name: debian-8 - driver: - image: debian:8 - pid_one_command: /bin/systemd - intermediate_instructions: - - RUN /usr/bin/apt-get update - - RUN /usr/bin/apt-get install apt-transport-https lsb-release procps net-tools -y - -- name: centos-6 - driver: - image: centos:6 - platform: rhel - pid_one_command: /sbin/init - intermediate_instructions: - - RUN yum -y install lsof which initscripts net-tools wget net-tools - -- name: centos-7 - driver: - image: centos:7 - platform: rhel - pid_one_command: /usr/lib/systemd/systemd - intermediate_instructions: - - RUN yum -y install lsof which systemd-sysv initscripts wget net-tools - -- name: fedora-latest - driver: - image: fedora:latest - pid_one_command: /usr/lib/systemd/systemd - intermediate_instructions: - - RUN dnf -y install which systemd-sysv initscripts wget net-tools - -- name: ubuntu-14.04 - driver: - image: ubuntu-upstart:14.04 - pid_one_command: /sbin/init - intermediate_instructions: - - RUN /usr/bin/apt-get update - - RUN /usr/bin/apt-get install apt-transport-https lsb-release procps net-tools -y - -- name: ubuntu-16.04 - driver: - image: ubuntu:16.04 - pid_one_command: /bin/systemd - intermediate_instructions: - - RUN /usr/bin/apt-get update - - RUN /usr/bin/apt-get install apt-transport-https lsb-release procps net-tools -y - -- name: opensuse-leap - driver: - image: opensuse:leap - pid_one_command: /bin/systemd - intermediate_instructions: - - RUN zypper --non-interactive install aaa_base perl-Getopt-Long-Descriptive which net-tools - -suites: - - name: server_package - run_list: - - recipe[nagios::default] - - recipe[nrpe::default] - - recipe[nagios_test::default] - - role[monitoring] - attributes: - nagios: - - name: server_source - run_list: - - recipe[nagios::default] - - recipe[nrpe::default] - - recipe[nagios_test::default] - - role[monitoring] - attributes: - nagios: - server: - install_method: 'source' - - name: pagerduty - run_list: - - recipe[nagios::default] - - recipe[nagios::pagerduty] - - recipe[nrpe::default] - - recipe[nagios_test::default] - - role[monitoring] - attributes: - nagios: - server: - install_method: 'source' - pagerduty: - key: 'your_key_here_3eC2' - - name: allowed_ips - run_list: - - recipe[nagios::default] - - recipe[nrpe::default] - - recipe[nagios_test::default] - - role[monitoring] - attributes: - nagios: - allowed_ips: ['127.0.0.1', '::1'] -data_bags_path: test/data_bags -roles_path: test/roles diff --git a/cookbooks/nagios/.kitchen.yml b/cookbooks/nagios/.kitchen.yml deleted file mode 100644 index 277dbfca8..000000000 --- a/cookbooks/nagios/.kitchen.yml +++ /dev/null @@ -1,98 +0,0 @@ -driver: - name: vagrant - -provisioner: - name: chef_zero - -platforms: - - name: ubuntu-14.04 - driver_config: - network: - - ["forwarded_port", {guest: 80, host: 8081}] - run_list: - - recipe[apt::default] - - name: ubuntu-16.04 - driver_config: - network: - - ["forwarded_port", {guest: 80, host: 8082}] - run_list: - - recipe[apt::default] - - name: debian-7.11 - driver_config: - network: - - ["forwarded_port", {guest: 80, host: 8083}] - run_list: - - recipe[apt::default] - - name: debian-8.6 - driver_config: - network: - - ["forwarded_port", {guest: 80, host: 8084}] - run_list: - - recipe[apt::default] - - name: freebsd-10.3 - driver_config: - network: - - ["forwarded_port", {guest: 80, host: 8085}] - - name: freebsd-11.0 - driver_config: - network: - - ["forwarded_port", {guest: 80, host: 8086}] - - name: centos-7.3 - driver_config: - network: - - ["forwarded_port", {guest: 80, host: 8087}] - - name: centos-6.8 - driver_config: - network: - - ["forwarded_port", {guest: 80, host: 8088}] - - name: centos-5.11 - driver_config: - network: - - ["forwarded_port", {guest: 80, host: 8089}] - - name: fedora-25 - driver_config: - network: - - ["forwarded_port", {guest: 80, host: 8090}] -suites: - - name: server_package - run_list: - - recipe[nagios::default] - - recipe[nrpe::default] - - recipe[nagios_test::default] - - role[monitoring] - attributes: - nagios: - - name: server_source - run_list: - - recipe[nagios::default] - - recipe[nrpe::default] - - recipe[nagios_test::default] - - role[monitoring] - attributes: - nagios: - server: - install_method: 'source' - - name: pagerduty - run_list: - - recipe[nagios::default] - - recipe[nagios::pagerduty] - - recipe[nrpe::default] - - recipe[nagios_test::default] - - role[monitoring] - attributes: - nagios: - server: - install_method: 'source' - pagerduty: - key: 'your_key_here_3eC2' - - name: allowed_ips - run_list: - - recipe[nagios::default] - - recipe[nrpe::default] - - recipe[nagios_test::default] - - role[monitoring] - attributes: - nagios: - allowed_ips: ['127.0.0.1', '::1'] -data_bags_path: test/data_bags -roles_path: test/roles diff --git a/cookbooks/nagios/.travis.yml b/cookbooks/nagios/.travis.yml deleted file mode 100644 index 508bc703c..000000000 --- a/cookbooks/nagios/.travis.yml +++ /dev/null @@ -1,44 +0,0 @@ -sudo: required -dist: trusty - -addons: - apt: - sources: - - chef-stable-trusty - packages: - - chefdk - -# Don't `bundle install` which takes about 1.5 mins -install: echo "skip bundle install" - -branches: - only: - - master - -services: docker - -env: - matrix: - - INSTANCE=server-package-centos-6 - - INSTANCE=server-package-centos-7 - - INSTANCE=server-package-debian-7 - - INSTANCE=server-package-debian-8 - - INSTANCE=server-package-fedora-latest - - INSTANCE=server-package-opensuse-leap - - INSTANCE=server-package-ubuntu-1404 - - INSTANCE=server-package-ubuntu-1604 - -before_script: - - sudo iptables -L DOCKER || ( echo "DOCKER iptables chain missing" ; sudo iptables -N DOCKER ) - - eval "$(/opt/chefdk/bin/chef shell-init bash)" - - /opt/chefdk/embedded/bin/chef --version - - /opt/chefdk/embedded/bin/cookstyle --version - - /opt/chefdk/embedded/bin/foodcritic --version - -script: KITCHEN_LOCAL_YAML=.kitchen.dokken.yml /opt/chefdk/embedded/bin/kitchen verify ${INSTANCE} - -matrix: - include: - - script: - - /opt/chefdk/bin/chef exec delivery local all - env: UNIT_AND_LINT=1 diff --git a/cookbooks/nagios/Berksfile b/cookbooks/nagios/Berksfile deleted file mode 100644 index cb2af9911..000000000 --- a/cookbooks/nagios/Berksfile +++ /dev/null @@ -1,11 +0,0 @@ -source 'https://supermarket.chef.io' -source chef_repo: ".." - -metadata - -# Mu Cookbooks - -# Supermarket Cookbooks -cookbook 'apache2', '< 4.0' -cookbook 'php', '< 6.0' -cookbook 'zap', '>= 0.6.0' \ No newline at end of file diff --git a/cookbooks/nagios/CHANGELOG.md b/cookbooks/nagios/CHANGELOG.md deleted file mode 100644 index a9cc8ad0c..000000000 --- a/cookbooks/nagios/CHANGELOG.md +++ /dev/null @@ -1,589 +0,0 @@ -nagios Cookbook CHANGELOG -========================= -This file is used to list changes made in each version of the nagios cookbook. -7.2.6 ------ -### Bug -- #445 Fixing escalation_periods. -- #448 Fixing service escalations. -- #459 Fixing undefined method `push'. -- #453 Fixing nodes without any tags. - -### Improvement -- #443 Merging the timezone settings. -- #450 Allowing default guest user. -- #454 Adding inheritance modifiers. -- #462 Adding Apache LDAP settings. -- #463 Adding '*' and 'null' as options. -- #470 Adding option for wrapper cookbooks. -- #470 Adding result_limit to cgi.cfg. - -7.2.4 ------ -### Bug -- #419 Fixing the nagios_interval logic and readme. -- #421 Fixing loading of pagerduty databag contacts. -- #430 Fixing loading of timeperiods out of databag with ducktyping. -- #437 Fixing loading of unmanaged_host databag regards to environments. -- #441 Enable setting of Fixnum's within nagios configuration attributes. - -### Improvement -- #426 Added command: service_notify_by_sms_email. -- #435 Adding pagerduty.cgi and needed packages - -7.2.2 ------ -### Bug -- Fixing the apache mpm breaking on centos. - -7.2.0 ------ -### Testing -- Added centos 7.1 for testing. -- Added centos 5.11 for testing. -- Added test-kitchen tests. - -### Improvement -- Added logic to exclude nodes based on tag. -- Including apache2::mpm_prefork for apache. -- Added the ability to specify command arguments within services. -- Added the ability to specify custom options on hosts, contacts and services. - -7.1.8 ------ -### Bug -- Fixing the unmanagedhosts databag filter on environment. -- Fixing the services databag filter on environment. - -### Improvement -- Moving the LWRP's providers into definitions. - This will remove some extra complexity and output will be - much nicer and debugging will be easier during the chef-converge. - -7.1.6 ------ -### Bug -- Fixing the nagios_[resource] provider delete action. - -### Improvement -- Added option for custom apache auth based on attribute. -- Update cgi-path attibute on source install. -- Update on test-kitchen tests. -- Update on kitchen-vagrant version. - -7.1.4 ------ -### Bug -- AuthzLDAPAuthoritative is removed in Apache 2.4. -- Fixed the pagerduty config by using LWRP. - -### Improvement -- Made test config os (in)dependent. -- Added zap for config file cleanup. -- Added encrypted user databag support. -- Added extra configuration tests. -- Added gitter badge. - -7.1.2 ------ -### Bug -- Fixed display of style sheets on Ubuntu 14.04+ -- service_check_timeout_state config option is now only set on modern Nagios releases. This broke Ubuntu 10.04/12.04 service startup -- Updated Test Kitchen release / added additional platforms for testing -- Fixed the attribute used to enable notifications in the Readme file -- Fixed loading of node['nagios']['host_name_attribute'] - -### Improvement -- Search queries in hostgroups data bag are now limited to the monitored environments if using node['nagios']['monitored_environments'] - -7.1.0 ------ -### Bug -- Fixed class-type checking with duck-typing on update_options. -- Fixed host_name_attribute on nagios model. - -### Improvement -- Moved all nagios configuration options within attributes. -- Moved all nagios configuration attributes into separate file. - -### Breaking Changes -- With the change above we might introduced some config problems. - Please check your attributes when upgrading. - -### Development -- Added extra kitchen serverspec tests. - -7.0.8 ------ -### Bug -- Fixed servicegroups members. -- Chaned the order of data bag loading (commands first). - -### Improvement -- Cleanup of the internals of the nagios model. - -### Development -- Added kitchen serverspec tests. - -7.0.6 ------ -### Bug -- Fixed data bag import.(#346) -- Fixed missing create method on Servicegroup object. (#348) -- Fixed update_dependency_members for depedency objects. - -7.0.4 ------ -### Bug -- Fixed the order for resource.cfg population to be correct. - -7.0.2 ------ -### Bug -- Fixed the hardcoded cgi-bin path in server source. -- Fixed contact_groups within load_default_config recipe. -- Removed dead code from timeperiod.rb library. -- Ignore timeperiods that don't comply. -- Making time formats less restrictive. (#336) - -### Improvement -- Make yum-epel recipe include optional via attribute. -- Only allow_empty_hostgroup_assignment for Nagios versions >= 3.4.0 - -7.0.0 ------ -### Feature -- Added providers for all nagios configuration objects. -- Added wiki pages explaining the providers. -- Added wiki pages explaining the databags. - -### Development -- Updated chefspec (4.2.0) - -### Extra note -- Please test this version before using it in production. Some logic and attributes have changes, so this might break your current setup. - -6.1.2 ----------- -### Feature -- Allow defining parents in the unmanaged hosts data bag so you can build the host map. - -### Bug -- Setup Apache2 before trying to configure the webserver so paths will be created -- Installed EPEL on RHEL so package installs work -- Set the Apache log dir to that provided by Apache since the Nagios log dir is now locked down to just the nagios user / group -- Template the resource.cfg file on RHEL platforms to prevent check failures -- Fix cgi-bin page loads on RHEL systems -- Fix CSS files not loading on Debian based systems - -### Development -- Updated Test Kitchen dependency to 1.3.1 from 1.2.1 - -6.1.0 ------ - -### Bug -- Fix missing CSS files on RHEL/Fedora package installs -- Ensure the source file for Nagios is always downloaded to work around corrupt partial downloads -- Fixed permissions being changed on the resource directory during each run on RHEL systems - -### Improvement -- Remove support for SSL V2 / V3 (Apache2/NGINX) and add TLS 1.1 and 1.2 (NGINX) -- Cleaned up and removed duplicate code from the web server configuration - -### New Features -- Added the ability to tag nodes with an attribute that excludes them from the monitoring search. See readme for details - -### Breaking Changes -- The /nagios or /nagios3 URLs are no longer valid. Nagios should be installed on the root of the webserver and this never entirely worked - -### Development -- Updated Rubocop rules -- Fixed specs to run with Chefspec 4.X - -v6.0.4 ------- -### Bug -- Fix normalized hostnames not normalizing the hostgroups -- Don't register the service templates so that Nagios will start properly -- Require Apache2 cookbook version 2.0 or greater due to breaking changes with how site.conf files are handled - -### Improvement -- Added additional options for perfdata - -### New Feature -- Added the ability to specify a URL to download patches that will be applied to the source install prior to compliation - - -v6.0.2 ------- -### Bug -- Remove .DS_Store files in the supermarket file that caused failures on older versions of Berkshelf - -v6.0.0 ------- -### Breaking changes -- NRPE is no longer installed by the nagios cookbook. This is handled by the NRPE cookbook. Moving this logic allows for more fined grained control of how the two services are installed and configured -- Previously the Nagios server was monitored out of the box using a NRPE check. This is no longer the case since the cookbooks are split. You'll need to add a services data bag to return this functionality -- RHEL now defaults to installing via packages. If you would like to continue installing via source make sure to set the installation_method attribute -- node['nagios']['additional_contacts'] attribute has been removed. This was previously used for Pagerduty integration -- Server setup is now handled in the nagios::default recipe vs. the nagios::server recipe. You will need to update roles / nodes referencing the old recipe - -### Bug -- htpasswd file should be setup after Nagios has been installed to ensure the user has been created -- Ensure that the Linux hostgroup still gets created even if the Nagios server is the first to come up in the environment -- Correctly set the vname on RHEL/Fedora platforms for source/package installs -- Set resource_dir in nagios.cfg on RHEL platforms with a new attribute -- Create the archives dir in the log on source installs -- Properly create the Nagios user/group on source installs -- Properly set the path for the p1.pl file on RHEL platforms -- Ensure that the hostgroups array doesn't include duplicates in the even that an environment and role have the same name -- Only template nagios.cfg once -- Fix ocsp-command typo in nagios.cfg -- Fix bug that prevented Apache2 recipe from completing - -### Improvement -- Readme cleanup -- Created a new users_helper library to abstract much of the Ruby logic for building user lists out of the recipe -- Avoid writing out empty comments in templates for data bag driven configs -- Add a full chefignore file to help with Berkshelf -- Better documented host_perfdata_command and service_perfdata_command in the README -- Add possibility to configure default_service with options process_perf_data & action_url -- Add possibility to configure default_host with options process_perf_data & action_url -- Allow freshness_threshold and active_checks_enabled to be specified in templates -- Added a generic service-template w/min req. params - -### New Feature -- New attribute node['nagios']['monitored_environments'] for specifying multiple environments you'd like to monitor -- Allow using the exclusion hostgroup format used by Nagios when defining the hostgroup for a check -- Host templates can now be defined via a new host_templates data bag. - - -### Development -- Vagrantfile updated for Vagrant 1.5 format changes -- Updated Rubocop / Foodcritic / Chefspec / Berkshelf gems to the latest for Travis testing -- Updated Berkshelf file to the 3.0 format -- Updated Test Kitchen / Kitchen Vagrant gems to the latest for local testing -- Test Kitchen suite added for source installs -- Ubuntu 13.04 swapped for 14.04 in Test Kitchen -- Added a large number of data bags to be used by Test Kitchen to handle several scenarios -- Setup port forwarding in Test Kitchen so you can converge the nodes and load the Web UI -- Added additional Test Kitchen and Chef Spec tests - -v5.3.4 ------- -### Bug -- Fixed two bugs that prevented Apache/NGINX web server setups from configuring correctly - -v5.3.2 ------- -### Bug -- Remove a development file that was accidentally added to the community site release - -v5.3.0 ------- -### Breaking changes -- Directories for RHEL installations have been updated to use correct RHEL directories vs. Debian directories. You may need to override these directories with the existing directories to not break existing installations on RHEL. Proceed with caution. - -### Bug -- Cookbook no longer fails the run if a node has no roles -- Cookbook no longer fails if there are no users defined in the data bag -- Cookbook no longer fails if a node has no hostname -- Cookbook no longer fails if the node does not have a defined OS -- Fix incorrect Pagerduty key usage -- Allowed NRPE hosts were not being properly determined due to bad logic and a typo - -### Improvement -- Improve Test-Kitchen support with newer RHEL point releases, Ubuntu 13.04, and Debian 6/7 -- Simplified logic in web server detection for determining public domain and switches from symbols to strings throughout - -### New Feature -- Support for Nagios host escalations via a new data bag. See the readme for additional details -- New attribute node['nagios']['monitoring_interface'] to allow specifying a specific network interface's IP to monitor -- You can now define the values for execute_service_checks, accept_passive_service_checks, execute_host_checks, and accept_passive_host_checks via attributes -- You can now define the values for obsess_over_services and obsess_over_hosts settings via attributes - - -v5.2.0 ------- -### Breaking changes -- This release requires yum-epel, which requires the yum v3.0 cookbook. This may break other cookbooks in your environment - -### Bug -- Change yum cookbook dependency to yum-epel dependecy as yum cookbook v3.0 removed epel repo setup functionality -- Several fixes to the Readme examples - -### Improvement -- Use the new monitoring-plugins.org address for the Nagios Plugins during source installs -- The version of apt defined in the Berksfile is no longer constrained -- Find all nodes by searching by node not hostname to workaround failures in ohai determining the hostname - -### New Feature -- Allow defining of time periods via new data bag nagios_timeperiods. See the Readme for additional details - - -v5.1.0 ------- -### Bug -- **[COOK-3210](https://tickets.opscode.com/browse/COOK-3210)** Contacts are now only written out if the contact has Nagios keys defined, which prevents e-mail-less contacts from being written out -- **[COOK-4098](https://tickets.opscode.com/browse/COOK-4098)** Fixed an incorrect example for using templates in the readme -- Fixed a typo in the servicedependencies.cfg.erb template that resulted in hostgroup_name always being blank - -### Improvement -- The Yum cookbook dependency has been pinned to < 3.0 to prevent breakage when the 3.0 cookbook is released -- **[COOK-2389](https://tickets.opscode.com/browse/COOK-2389)** The logic used to determine what IP to identify the monitored host by has been moved into the default library to simplify the hosts.cfg.erb template -- A Vagrantfile has been added to allow for testing on Ubuntu 10.04/12.04 and CentOS 5.9/6.4 in multi-node setups -- Chef spec tests have been added for the server -- Gemfile updated to use Rubocop 0.15 and TestKitchen 1.0 -- **[COOK-3913](https://tickets.opscode.com/browse/COOK-3913)** / **[COOK-3914](https://tickets.opscode.com/browse/COOK-3914)** Source based installations now use Nagios 3.5.1 and the Nagios Plugins 1.5.0 - -### New Feature -- The names of the various data bags used in the cookbook can now be controlled with new attributes found in the server.rb attribute file -- All configuration options in the cgi.cfg and nrpe.cfg files can now be controlled via attributes -- **[COOK-3690](https://tickets.opscode.com/browse/COOK-3690)** An intermediate SSL certificate can now be used on the web server as defined in the new attribute `node['nagios']['ssl_cert_chain_file']` -- **[COOK-2732](https://tickets.opscode.com/browse/COOK-2732)** A service can now be applied to multiple hostgroups via the data bag definition -- **[COOK-3781](https://tickets.opscode.com/browse/COOK-3781)** Service escalations can now be written using wildcards. See the readme for an example of this feature. -- **[COOK-3702](https://tickets.opscode.com/browse/COOK-3702)** Multiple PagerDuty keys for different contacts can be defined via a new nagios_pagerduty data bag. See the readme for more information on the new data bag and attributes for this feature. -- **[COOK-3774](https://tickets.opscode.com/browse/COOK-3774)**Services can be limited to run on nagios servers in specific chef environments by adding a new "activate_check_in_environment" key to the services data bag. See the Services section of the readme for an example. -- **[CHEF-4702](https://tickets.opscode.com/browse/CHEF-4702)** Chef solo users can now user solo-search for data bag searchd (https://github.com/edelight/chef-solo-search) - -v5.0.2 ------- -### Improvement -- **[COOK-3777](https://tickets.opscode.com/browse/COOK-3777)** - Update NRPE in nagios cookbook to 2.15 -- **[COOK-3021](https://tickets.opscode.com/browse/COOK-3021)** - NRPE LWRP updates files every run -- Fixing up to pass rubocop - - -v5.0.0 ------- -### Bug -- **[COOK-3778](https://tickets.opscode.com/browse/COOK-3778)** - Fix missing customization points for Icinga -- **[COOK-3731](https://tickets.opscode.com/browse/COOK-3731)** - Remove range searches in Nagios cookbook that break chef-zero -- **[COOK-3729](https://tickets.opscode.com/browse/COOK-3729)** - Update Nagios Plugin download URL -- **[COOK-3579](https://tickets.opscode.com/browse/COOK-3579)** - Stop shipping icons files that arent used -- **[COOK-3332](https://tickets.opscode.com/browse/COOK-3332)** - Fix `nagios::client` failures on Chef Solo - -### Improvement -- **[COOK-3730](https://tickets.opscode.com/browse/COOK-3730)** - Change the default authentication method -- **[COOK-3696](https://tickets.opscode.com/browse/COOK-3696)** - Sort hostgroups so they don't get updated on each run -- **[COOK-3670](https://tickets.opscode.com/browse/COOK-3670)** - Add Travis support -- **[COOK-3583](https://tickets.opscode.com/browse/COOK-3583)** - Update Nagios source to 3.5.1 -- **[COOK-3577](https://tickets.opscode.com/browse/COOK-3577)** - Cleanup code style -- **[COOK-3287](https://tickets.opscode.com/browse/COOK-3287)** - Provide more customization points to make it possible to use Icinga -- **[COOK-1725](https://tickets.opscode.com/browse/COOK-1725)** - Add configurable notification options for `nagios::pagerduty` - -### New Feature -- **[COOK-3723](https://tickets.opscode.com/browse/COOK-3723)** - Support regexp_matching in Nagios -- **[COOK-3695](https://tickets.opscode.com/browse/COOK-3695)** - Add more tunables for default host template - - -v4.2.0 ------- -### New Feature -- **[COOK-3445](https://tickets.opscode.com/browse/COOK-3445)** - Allow setting service dependencies from data dags -- **[COOK-3429](https://tickets.opscode.com/browse/COOK-3429)** - Allow setting timezone from attribute -- **[COOK-3422](https://tickets.opscode.com/browse/COOK-3422)** - Enable large installation tweaks by attribute - -### Improvement -- **[COOK-3440](https://tickets.opscode.com/browse/COOK-3440)** - Permit additional pagerduty-like integrations -- **[COOK-3136](https://tickets.opscode.com/browse/COOK-3136)** - Fix `nagios::client_source` under Gentoo -- **[COOK-3111](https://tickets.opscode.com/browse/COOK-3111)** - Add support for alternate users databag to Nagios cookbook -- **[COOK-2891](https://tickets.opscode.com/browse/COOK-2891)** - Improve RHEL 5 detection in Nagios cookbook to catch all versions -- **[COOK-2721](https://tickets.opscode.com/browse/COOK-2721)** - Add Chef Solo support - -### Bug -- **[COOK-3405](https://tickets.opscode.com/browse/COOK-3405)** - Fix NRPE source install on Ubuntu -- **[COOK-3404](https://tickets.opscode.com/browse/COOK-3404)** - Fix `htpasswd` file references (Chef 11 fix) -- **[COOK-3282](https://tickets.opscode.com/browse/COOK-3282)** - Use `host_name` attribute when used in conjunction with a search-defined hostgroup -- **[COOK-3162](https://tickets.opscode.com/browse/COOK-3162)** - Allow setting port -- **[COOK-3140](https://tickets.opscode.com/browse/COOK-3140)** - No longer import databag users even if they don't have an `htpasswd` value set -- **[COOK-3068](https://tickets.opscode.com/browse/COOK-3068)** - Use `nagios_conf` definition in `nagios::pagerduty` - - -v4.1.4 ------- -### Bug -- [COOK-3014]: Nagios cookbook imports data bag users even if they have action `:remove` - -### Improvement -- [COOK-2826]: Allow Nagios cookbook to configure location of SSL files - -v4.1.2 ------- -### Bug -- [COOK-2967]: nagios cookbook has foodcritic failure - -### Improvement -- [COOK-2630]: Improvements to Readme and Services.cfg.erb template - -### New Feature -- [COOK-2460]: create attribute for `allowed_hosts` - - -v4.1.0 ------- -- [COOK-2257] - Nagios incorrectly tries to use cloud IPs due to a OHAI bug -- [COOK-2474] - hosts.cfg.erb assumes if nagios server node has the cloud attributes all nodes have the cloud attributes -- [COOK-1068] - Nagios::client should support CentOS/RHEL NRPE installs via package -- [COOK-2565] - nginx don't send `AUTH_USER` & `REMOTE_USER` to nagios -- [COOK-2546] - nrpe config files should not be world readable -- [COOK-2558] - Services that are attached to hostgroups created from the nagios_hostgroups databag are not created -- [COOK-2612] - Nagios can't start if search can't find hosts defined in nagios_hostgroups -- [COOK-2473] - Install Nagios 3.4.4 for source installs -- [COOK-2541] - Nagios cookbook should use node.roles instead of node.run_list.roles when calculating hostgroups -- [COOK-2543] - Adds the ability to normalize hostnames to lowercase -- [COOK-2450] - Add ability to define service groups through data bags. -- [COOK-2642] - With multiple nagios servers, they can't use NRPE to check each other -- [COOK-2613] - Install Nagios 3.5.0 when installing from source - - -v4.0.0 ------- -This is a major release that refactors a significant amount of the service configuration to use data bags rather than hardcoding specific checks in the templates. The README describes how to create services via data bags. - -The main incompatibility and breaking change is that the default services that are monitored by Nagios is reduced to only the "check-nagios" service. This means that existing installations will need to start converting checks over to the new data bag entries. - -- [COOK-1553] - Nagios: check_nagios command does not work if Nagios is installed from source -- [COOK-1554] - Nagios: The nagios server should be added to all relevant host groups -- [COOK-1746] - nagios should provide more flexibility for server aliases -- [COOK-2006] - Extract default checks out of nagios -- [COOK-2129] - If a host is in the _default environment it should go into the _default hostgroup -- [COOK-2130] - Chef needs to use the correct nagios plugin path on 64bit CentOS systems -- [COOK-2131] - gd development packages are not necessary for NRPE installs from source -- [COOK-2132] - Update NRPE installs to 2.14 from 2.13 -- [COOK-2134] - Handle nagios-nrpe-server and nrpe names for NRPE in the init scripts and cookbook -- [COOK-2135] - Use with-nagios-user and group options source NRPE installs -- [COOK-2136] - Nagios will not pass config check when multiple machines in different domains have the same hostname -- [COOK-2150] - hostgroups data bag search doesn't respect the multi_environment_monitoring attribute -- [COOK-2186] - add service escalation to nagios -- [COOK-2188] - A notification interval of zero is valid but prohibited by the cookbook -- [COOK-2200] - Templates and Services from data bags don't specify intervals in the same way as the rest of the cookbook -- [COOK-2216] - Nagios cookbook readme needs improvement -- [COOK-2240] - Nagios server setup needs to gracefully fail when users data bag is not present -- [COOK-2241] - Stylesheets fail to load on a fresh Nagios install -- [COOK-2242] - Remove unused checks in the NRPE config file -- [COOK-2245] - nagios::server writes openid apache configs before including apache2::mod_auth_openid -- [COOK-2246] - Most of the commands in the Nagios cookbook don't work -- [COOK-2247] - nagios::client_source sets pkgs to a string, then tries to pkgs.each do {|pkg| package pkg } -- [COOK-2257] - Nagios incorrectly tries to use cloud IPs due to a OHAI bug -- [COOK-2275] - The Nagios3 download URL attribute is unused -- [COOK-2285] - Refactor data bag searches into library -- [COOK-2294] - Add cas authentication to nagios cookbook -- [COOK-2295] - nagios: chef tries to start nagios-nrpe-server on every run -- [COOK-2300] - You should be able to define a nagios_service into the "all" host group -- [COOK-2341] - pagerduty_nagios.pl URL changed -- [COOK-2350] - Nagios server fails to start when installed via source on Ubuntu/Debian -- [COOK-2369] - Add LDAP support in the nagios cookbook. -- [COOK-2374] - Setting an unmanaged host to a string returns 'no method error' -- [COOK-2375] - Allows adding a service that utilizes a pre-existing command -- [COOK-2433] - Nagios: ldap authentication needs to handle anonymous binding ldap servers - - -v3.1.0 ------- -- [COOK-2032] - Use public IP address for inter-cloud checks and private for intra-cloud checks -- [COOK-2081] - add support for `notes_url` to `nagios_services` data bags - - -v3.0.0 ------- -This is a major release due to some dramatic refactoring to the service check configuration which may not be compatible with existing implementations of this cookbook. - -- [COOK-1544] - Nagios cookbook needs to support event handlers -- [COOK-1785] - Template causes service restart every time -- [COOK-1879] - Nagios: add configuration to automatically redirect http://myserver/ to http://myserver/nagios3/ -- [COOK-1880] - Extra attribute was left over after the `multi_environment_monitoring` update -- [COOK-1881] - Oracle should be added to the metadata for Nagios -- [COOK-1891] - README says to modify the nrpe.cfg template, but the cookbook exports a resource for nrpe checks. -- [COOK-1947] - Nagios: Pager duty portions of Nagios cookbook not using nagios user/group attributes -- [COOK-1949] - Nagios: A bad role on a node shouldn't cause the cookbook to fail -- [COOK-1950] - Nagios: Simplify hostgroup building and cookbook code -- [COOK-1995] - Nagios: Update source install to use Nagios 3.4.3 not 3.4.1 -- [COOK-2005] - Remove unusable check commands from nagios -- [COOK-2031] - Adding templates as a data bag, extending service data bag to take arbitrary config items -- [COOK-2032] - Use public IP address for intra-cloud checks -- [COOK-2034] - Nagios cookbook calls search more often than necessary -- [COOK-2054] - Use service description in the nagios_services databag items -- [COOK-2061] - template.erb refers to a service variable when it should reference template. - - -v2.0.0 ------- -- [COOK-1543] - Nagios cookbook needs to be able to monitor environments -- [COOK-1556] - Nagios: Add ability to define service template to be used in the `nagios_services` data bag -- [COOK-1618] - Users data bag group allowed to log into Nagios should be configurable -- [COOK-1696] - Nagios: Support defining non-Chef managed hosts via data bag items -- [COOK-1697] - nagios: Source installs should install the latest NRPE and Nagios plugins -- [COOK-1717] - Nagios: nagios server web page under Apache2 fails to load out of the box -- [COOK-1723] - Amazon missing as a supported OS in the Nagios metadata -- [COOK-1732] - `nagios::client_source` includes duplicate resources -- [COOK-1815] - Switch Nagios to use platform_family not platform -- [COOK-1816] - Nagios: mod ssl shouldn't get installed if SSL isn't being used -- [COOK-1887] - `value_for_platform_family` use in Nagios cookbook is broken - - -v1.3.0 ------- -- [COOK-715] - don't source /etc/sysconfig/network on non-RHEL platforms -- [COOK-769] - don't use nagios specific values in users data bag items if they don't exist -- [COOK-1206] - add nginx support -- [COOK-1225] - corrected inconsistencies (mode, user/group, template headers) -- [COOK-1281] - add support for amazon linux -- [COOK-1365] - nagios_conf does not use nagios user/group attributes -- [COOK-1410] - remvoe deprecated package resource -- [COOK-1411] - Nagios server source installs should not necessarily install the NRPE client from source -- [COOK-1412] - Nagios installs from source do not install a mail client so notifications fail -- [COOK-1413] - install nagios 3.4.1 instead of 3.2.3 -- [COOK-1518] - missing sysadmins variable in apache recipe -- [COOK-1541] - support environments that have windows systems -- [COOK-1542] - allow setting flap detection via attribute -- [COOK-1545] - add support for defining host groups using search in data bags -- [COOK-1553] - check_nagios command doesn't work from source install -- [COOK-1555] - include service template for monitoring logs -- [COOK-1557] - check-nagios command only works in environments with single nagios server -- [COOK-1587] - use default attributes instead of normal in cookbook attributes files - - -V1.2.6 ------- -- [COOK-860] - set mail command with an attribute by platform - - -v1.2.4 ------- -- [COOK-1119] - attributes for command_timeout / dont_blame_nrpe options -- [COOK-1120] - allow monitoring from servers in multiple chef_environments - - -v1.2.2 ------- -- [COOK-991] - NRPE LWRP No Longer Requires a Template -- [COOK-955] - Nagios Service Checks Defined by Data Bags - - -v1.2.0 ------- -- [COOK-837] - Adding a Recipe for PagerDuty integration -- [COOK-868] - use node, not @node in template -- [COOK-869] - corrected NRPE PID path -- [COOK-907] - LWRP for defining NRPE checks -- [COOK-917] - changes to `mod_auth_openid` module - - -v1.0.4 ------- -- [COOK-838] - Add HTTPS Option to Nagios Cookbook - - -v1.0.2 ------- -- [COOK-636] - Nagios server recipe attempts to start too soon -- [COOK-815] - Nagios Config Changes Kill Nagios If Config Goes Bad - - -v1.0.0 ------- -- Use Chef 0.10's `node.chef_environment` instead of `node['app_environment']`. -- source installation support on both client and server sides -- initial RHEL/CentOS/Fedora support diff --git a/cookbooks/nagios/CONTRIBUTING.md b/cookbooks/nagios/CONTRIBUTING.md deleted file mode 100644 index aaabd2a02..000000000 --- a/cookbooks/nagios/CONTRIBUTING.md +++ /dev/null @@ -1,11 +0,0 @@ -If you would like to contribute, please open a pull request here on -Github. - -Please do not modify the version number in the metadata.rb. Also please -do not update the CHANGELOG.md. Not all changes to the cookbook may -be merged and released in the same versions. I will handle the version -updates during the release process. - -If your change adds new attributes, data bags, or other features -please document how to use the change in the cookbook's README.md file. -Otherwise no one will know how to use your work. diff --git a/cookbooks/nagios/Gemfile b/cookbooks/nagios/Gemfile deleted file mode 100644 index f63277239..000000000 --- a/cookbooks/nagios/Gemfile +++ /dev/null @@ -1,9 +0,0 @@ -# This gemfile provides additional gems for testing and releasing this cookbook -# It is meant to be installed on top of ChefDK which provides the majority -# of the necessary gems for testing this cookbook -# -# Run 'chef exec bundle install' to install these dependencies - -source 'https://rubygems.org' - -gem 'stove' diff --git a/cookbooks/nagios/LICENSE b/cookbooks/nagios/LICENSE deleted file mode 100644 index cd1bdd111..000000000 --- a/cookbooks/nagios/LICENSE +++ /dev/null @@ -1,37 +0,0 @@ -Through accessing, reading, or utilizing this software in any manner whatsoever -or through any means whatsoever, whether the access, reading or use is either -solely looking at this software or this software has been integrated into any -derivative work, the party accessing, reading, or utilizing the software -directly or indirectly agrees to abide by the following license. - -The eGlobalTech Cloud Automation Platform is the Copyright (c) 2014 of Global -Tech Inc. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, -this list of conditions and the following disclaimer in the documentation -and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its contributors -may be used to endorse or promote products derived from this software without -specific prior written permission. - -Global Tech, Inc. is the co-owner of any derivative works created with this -software. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - diff --git a/cookbooks/nagios/README.md b/cookbooks/nagios/README.md deleted file mode 100644 index 3f34526b0..000000000 --- a/cookbooks/nagios/README.md +++ /dev/null @@ -1,328 +0,0 @@ -# nagios cookbook - -[![Join the chat at https://gitter.im/schubergphilis/nagios](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/schubergphilis/nagios?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Build Status](https://travis-ci.org/schubergphilis/nagios.svg)](https://travis-ci.org/schubergphilis/nagios) [![Cookbook Version](https://img.shields.io/cookbook/v/nagios.svg)](https://supermarket.chef.io/cookbooks/nagios) - -Installs and configures Nagios server. Chef nodes are automatically discovered using search, and Nagios host groups are created based on Chef roles and optionally environments as well. - -## Requirements - -### Chef - -Chef version 12.1+ is required - -Because of the heavy use of search, this recipe will not work with Chef Solo, as it cannot do any searches without a server. - -This cookbook relies heavily on multiple data bags. See **Data Bag** below. - -The system running this cookbooks should have a role named 'monitoring' so that NRPE clients can authorize monitoring from that system. This role name is configurable via an attribute. See **Attributes** below. - -The functionality that was previously in the nagios::client recipe has been moved to its own NRPE cookbook at - -### Platform - -- Debian 7+ -- Ubuntu 12.04+ -- Red Hat Enterprise Linux (CentOS/Amazon/Scientific/Oracle) 5.X, 6.X - -**Notes**: This cookbook has been tested on the listed platforms. It may work on other platforms with or without modification. - -### Cookbooks - -- apache2 2.0 or greater -- build-essential -- chef_nginx -- nginx_simplecgi -- php -- yum-epel - -## Attributes - -### config - -[The config file](https://github.com/schubergphilis/nagios/blob/master/attributes/config.rb) contains the Nagios configuration options. Consult the [nagios documentation](http://nagios.sourceforge.net/docs/3_0/configmain.html) for available settings and allowed options. Configuration entries of which multiple entries are allowed, need to be specified as an Array. - -Example: `default['nagios']['conf']['cfg_dir'] = [ '/etc/nagios/conf.d' , '/usr/local/nagios/conf.d' ]` - -### default -* `node['nagios']['user']` - Nagios user, default 'nagios'. -* `node['nagios']['group']` - Nagios group, default 'nagios'. -* `node['nagios']['plugin_dir']` - location where Nagios plugins go, default '/usr/lib/nagios/plugins'. -* `node['nagios']['multi_environment_monitoring']` - Chef server will monitor hosts in all environments, not just its own, default 'false' -* `node['nagios']['monitored_environments']` - If multi_environment_monitoring is 'true' nagios will monitor nodes in all environments. If monitored_environments is defined then nagios will monitor only hosts in the list of environments defined. For ex: ['prod', 'beta'] will monitor only hosts in 'prod' and 'beta' chef_environments. Defaults to '[]' - and all chef environments will be monitored by default. -* `node['nagios']['monitoring_interface']` - If set, will use the specified interface for all nagios monitoring network traffic. Defaults to `nil` -* `node['nagios']['exclude_tag_host']` - If set, hosts tagged with this value will be excluded from nagios monitoring. Defaults to '' - -* `node['nagios']['server']['install_method']` - whether to install from package or source. Default chosen by platform based on known packages available for Nagios: debian/ubuntu 'package', redhat/centos/fedora/scientific: source -* `node['nagios']['server']['install_yum-epel']` - whether to install the EPEL repo or not (only applies to RHEL platform family). The default value is `true`. Set this to `false` if you do not wish to install the EPEL RPM; in this scenario you will need to make the relevant packages available via another method e.g. local repo, or install from source. -* `node['nagios']['server']['service_name']` - name of the service used for Nagios, default chosen by platform, debian/ubuntu "nagios3", redhat family "nagios", all others, "nagios" -* `node['nagios']['home']` - Nagios main home directory, default "/usr/lib/nagios3" -* `node['nagios']['conf_dir']` - location where main Nagios config lives, default "/etc/nagios3" -* `node['nagios']['resource_dir']` - location for recources, default "/etc/nagios3" -* `node['nagios']['config_dir']` - location where included configuration files live, default "/etc/nagios3/conf.d" -* `node['nagios']['log_dir']` - location of Nagios logs, default "/var/log/nagios3" -* `node['nagios']['cache_dir']` - location of cached data, default "/var/cache/nagios3" -* `node['nagios']['state_dir']` - Nagios runtime state information, default "/var/lib/nagios3" -* `node['nagios']['run_dir']` - where pidfiles are stored, default "/var/run/nagios3" -* `node['nagios']['docroot']` - Nagios webui docroot, default "/usr/share/nagios3/htdocs" -* `node['nagios']['enable_ssl']` - boolean for whether Nagios web server should be https, default false -* `node['nagios']['ssl_cert_file']` = Location of SSL Certificate File. default "/etc/nagios3/certificates/nagios-server.pem" -* `node['nagios']['ssl_cert_chain_file']` = Optional location of SSL Intermediate Certificate File. No default. -* `node['nagios']['ssl_cert_key']` = Location of SSL Certificate Key. default "/etc/nagios3/certificates/nagios-server.pem" -* `node['nagios']['http_port']` - port that the Apache/Nginx virtual site should listen on, determined whether ssl is enabled (443 if so, otherwise 80). Note: You will also need to configure the listening port for either NGINX or Apache within those cookbooks. -* `node['nagios']['server_name']` - common name to use in a server cert, default "nagios" -* `node['nagios']['server']['server_alias']` - alias name for the webserver for use with Apache. Defaults to nil -* `node['nagios']['ssl_req']` - info to use in a cert, default `/C=US/ST=Several/L=Locality/O=Example/OU=Operations/CN=#{node['nagios']['server_name']}/emailAddress=ops@#{node['nagios']['server_name']}` - -* `node['nagios']['server']['url']` - url to download the server source from if installing from source -* `node['nagios']['server']['version']` - version of the server source to download -* `node['nagios']['server']['checksum']` - checksum of the source files -* `node['nagios']['server']['patch_url']` - url to download patches from if installing from source -* `node['nagios']['server']['patches']` - array of patch filenames to apply if installing from source -* `node['nagios']['url']` - URL to host Nagios from - defaults to nil and instead uses FQDN - -* `node['nagios']['conf']['enable_notifications']` - set to 1 to enable notification. -* `node['nagios']['conf']['interval_length']` - minimum interval. Defaults to '1'. -* `node['nagios']['conf']['use_timezone']` - set the timezone for nagios AND apache. Defaults to UTC. - -* `node['nagios']['check_external_commands']` -* `node['nagios']['default_contact_groups']` -* `node['nagios']['default_user_name']` - Specify a defaut guest user to allow page access without authentication. **Only** use this if nagios is running behind a secure webserver and users have been authenticated in some manner. You'll likely want to change `node['nagios']['server_auth_require']` to `all granted`. Defaults to `nil`. -* `node['nagios']['sysadmin_email']` - default notification email. -* `node['nagios']['sysadmin_sms_email']` - default notification sms. -* `node['nagios']['server_auth_method']` - authentication with the server can be done with openid (using `apache2::mod_auth_openid`), cas (using `apache2::mod_auth_cas`),ldap (using `apache2::mod_authnz_ldap`), or htauth (basic). The default is htauth. "openid" will utilize openid authentication, "cas" will utilize cas authentication, "ldap" will utilize LDAP authentication, and any other value will use htauth (basic). -* `node['nagios']['cas_login_url']` - login url for cas if using cas authentication. -* `node['nagios']['cas_validate_url']` - validation url for cas if using cas authentication. -* `node['nagios']['cas_validate_server']` - whether to validate the server cert. Defaults to off. -* `node['nagios']['cas_root_proxy_url']` - if set, sets the url that the cas server redirects to after auth. -* `node['nagios']['ldap_bind_dn']` - DN used to bind to the server when searching for ldap entries. -* `node['nagios']['ldap_bind_password']` - bind password used with the DN provided for searching ldap. -* `node['nagios']['ldap_url']` - ldap url and search parameters. -* `node['nagios']['ldap_authoritative']` - accepts "on" or "off". controls other authentication modules from authenticating the user if this one fails. -* `node['nagios']['ldap_group_attribute']` - Set the Apache AuthLDAPGroupAttribute directive to a non-default value. -* `node['nagios']['ldap_group_attribute_is_dn']` - accepts "on" or "off". Set the Apache AuthLDAPGroupAttributeIsDN directive. Apache's default behavior is currently "on." -* `node['nagios']['ldap_verify_cert']` - accepts "on" or "off". Set the Apache mod_ldap LDAPVerifyServerCert directive. Apache's default behavior is currently "on." -* `node['nagios']['ldap_trusted_mode']` - Set the Apache mod_ldap LDAPTrustedMode directive. -* `node['nagios']['ldap_trusted_global_cert']` - Set the Apache mod_ldap LDAPTrustedGlobalCert directive. -* `node['nagios']['users_databag']` - the databag containing users to search for. defaults to users -* `node['nagios']['users_databag_group']` - users databag group considered Nagios admins. defaults to sysadmin -* `node['nagios']['services_databag']` - the databag containing services to search for. defaults to nagios_services -* `node['nagios']['servicegroups_databag']` - the databag containing servicegroups to search for. defaults to nagios_servicegroups -* `node['nagios']['templates_databag']` - the databag containing templates to search for. defaults to nagios_templates -* `node['nagios']['hostgroups_databag']` - the databag containing hostgroups to search for. defaults to nagios_hostgroups -* `node['nagios']['hosttemplates_databag']` - the databag containing host templates to search for. defaults to nagios_hosttemplates -* `node['nagios']['eventhandlers_databag']` - the databag containing eventhandlers to search for. defaults to nagios_eventhandlers -* `node['nagios']['unmanagedhosts_databag']` - the databag containing unmanagedhosts to search for. defaults to nagios_unmanagedhosts -* `node['nagios']['serviceescalations_databag']` - the databag containing serviceescalations to search for. defaults to nagios_serviceescalations -* `node['nagios']['hostescalations_databag']` - the databag containing hostescalations to search for. defaults to nagios_hostescalations -* `node['nagios']['contacts_databag']` - the databag containing contacts to search for. defaults to nagios_contacts -* `node['nagios']['contactgroups_databag']` - the databag containing contactgroups to search for. defaults to nagios_contactgroups -* `node['nagios']['servicedependencies_databag']` - the databag containing servicedependencies to search for. defaults to nagios_servicedependencies -* `node['nagios']['host_name_attribute']` - node attribute to use for naming the host. Must be unique across monitored nodes. Defaults to hostname -* `node['nagios']['regexp_matching']` - Attribute to enable [regexp matching](http://nagios.sourceforge.net/docs/3_0/configmain.html#use_regexp_matching). Defaults to 0. -* `node['nagios']['large_installation_tweaks']` - Attribute to enable [large installation tweaks](http://nagios.sourceforge.net/docs/3_0/largeinstalltweaks.html). Defaults to 0. -* `node['nagios']['templates']` - These set directives in the default host template. Unless explicitly overridden, they will be inherited by the host definitions for each discovered node and `nagios_unmanagedhosts` data bag. For more information about these directives, see the Nagios documentation for [host definitions](http://nagios.sourceforge.net/docs/3_0/objectdefinitions.html#host). -* `node['nagios']['hosts_template']` - Host template you want to inherit properties/variables from, default 'server'. For more information, see the nagios doc on [Object Inheritance](http://nagios.sourceforge.net/docs/3_0/objectinheritance.html). -* `node['nagios']['brokers']` - Hash of broker modules to include in the config. Hash key is the path to the broker module, the value is any parameters to pass to it. - - -* `node['nagios']['default_host']['flap_detection']` - Defaults to `true`. -* `node['nagios']['default_host']['process_perf_data']` - Defaults to `false`. -* `node['nagios']['default_host']['check_period']` - Defaults to `'24x7'`. -* `node['nagios']['default_host']['check_interval']` - In seconds. Must be divisible by `node['nagios']['interval_length']`. Defaults to `15`. -* `node['nagios']['default_host']['retry_interval']` - In seconds. Must be divisible by `node['nagios']['interval_length']`. Defaults to `15`. -* `node['nagios']['default_host']['max_check_attempts']` - Defaults to `1`. -* `node['nagios']['default_host']['check_command']` - Defaults to the pre-defined command `'check-host-alive'`. -* `node['nagios']['default_host']['notification_interval']` - In seconds. Must be divisible by `node['nagios']['interval_length']`. Defaults to `300`. -* `node['nagios']['default_host']['notification_options']` - Defaults to `'d,u,r'`. -* `node['nagios']['default_host']['action_url']` - Defines a action url. Defaults to `nil`. - -* `node['nagios']['default_service']['process_perf_data']` - Defaults to `false`. -* `node['nagios']['default_service']['action_url']` - Defines a action url. Defaults to `nil`. - -* `node['nagios']['server']['web_server']` - web server to use. supports Apache or Nginx, default "apache" -* `node['nagios']['server']['nginx_dispatch']` - nginx dispatch method. supports cgi or php, default "cgi" -* `node['nagios']['server']['stop_apache']` - stop apache service if using nginx, default false -* `node['nagios']['server']['redirect_root']` - if using Apache, should http://server/ redirect to http://server/nagios3 automatically, default false -* `node['nagios']['server']['normalize_hostname']` - If set to true, normalize all hostnames in hosts.cfg to lowercase. Defaults to false. - - These are nagios cgi.config options. - - * `node['nagios']['cgi']['show_context_help']` - Defaults to 1 - * `node['nagios']['cgi']['authorized_for_system_information']` - Defaults to '*' - * `node['nagios']['cgi']['authorized_for_configuration_information']` - Defaults to '*' - * `node['nagios']['cgi']['authorized_for_system_commands']` - Defaults to '*' - * `node['nagios']['cgi']['authorized_for_all_services']` - Defaults to '*' - * `node['nagios']['cgi']['authorized_for_all_hosts']` - Defaults to '*' - * `node['nagios']['cgi']['authorized_for_all_service_commands']` - Defaults to '*' - * `node['nagios']['cgi']['authorized_for_all_host_commands']` - Defaults to '*' - * `node['nagios']['cgi']['default_statusmap_layout']` - Defaults to 5 - * `node['nagios']['cgi']['default_statuswrl_layout']` - Defaults to 4 - * `node['nagios']['cgi']['result_limit']` - Defaults to 100 - * `node['nagios']['cgi']['escape_html_tags']` - Defaults to 0 - * `node['nagios']['cgi']['action_url_target']` - Defaults to '_blank' - * `node['nagios']['cgi']['notes_url_target']` - Defaults to '_blank' - * `node['nagios']['cgi']['lock_author_names']` - Defaults to 1 - - -Recipes -------- - -## Recipes - -### default - -Includes the correct client installation recipe based on platform, either `nagios::server_package` or `nagios::server_source`. - -The server recipe sets up Apache as the web front end by default. This recipe also does a number of searches to dynamically build the hostgroups to monitor, hosts that belong to them and admins to notify of events/alerts. - -Searches are confined to the node's `chef_environment` unless multi-environment monitoring is enabled. - -The recipe does the following: - -1. Searches for users in 'users' databag belonging to a 'sysadmin' group, and authorizes them to access the Nagios web UI and also to receive notification e-mails. -2. Searches all available roles/environments and builds a list which will become the Nagios hostgroups. -3. Places nodes in Nagios hostgroups by role / environment membership. -4. Installs various packages required for the server. -5. Sets up configuration directories. -6. Moves the package-installed Nagios configuration to a 'dist' directory. -7. Disables the 000-default VirtualHost present on Debian/Ubuntu Apache2 package installations. -8. Templates configuration files for services, contacts, contact groups, templates, hostgroups and hosts. -9. Enables the Nagios web UI. -10. Starts the Nagios server service - -### server_package - -Installs the Nagios server from packages. Default for Debian / Ubuntu systems. - -### server_source - -Installs the Nagios server from source. Default for Red Hat / Fedora based systems as native packages for Nagios are not available in the default repositories. - -### pagerduty - -Installs pagerduty plugin for nagios. If you only have a single pagerduty key, you can simply set a `node['nagios']['pagerduty_key']` attribute on your server. For multiple pagerduty key configuration see Pager Duty under Data Bags. - -This recipe was written based on the [Nagios Integration Guide](http://www.pagerduty.com/docs/guides/nagios-integration-guide) from PagerDuty which explains how to get an API key for your Nagios server. - -## Data Bags - -[See Wiki for more databag information](https://github.com/schubergphilis/nagios/wiki/config) - -### Pager Duty - -You can define pagerduty contacts and keys by creating nagios_pagerduty data bags that contain the contact and the relevant key. Setting admin_contactgroup to "true" will add this pagerduty contact to the admin contact group created by this cookbook. - -```javascript -{ - "id": "pagerduty_critical", - "admin_contactgroup": "true", - "key": "a33e5ef0ac96772fbd771ddcccd3ccd0" -} -``` - -You can add these contacts to any contactgroups you create. - -## Monitoring Role - -Create a role to use for the monitoring server. The role name should match the value of the attribute "`node['nrpe']['server_role']`" on your clients. By default, this is '`monitoring`'. For example: - -```ruby -# roles/monitoring.rb -name 'monitoring' -description 'Monitoring server' -run_list( - 'recipe[nagios::default]' -) - -default_attributes( - 'nagios' => { - 'server_auth_method' => 'htauth' - } -) -``` - -```bash -$ knife role from file monitoring.rb -``` - -## Usage - -### server setup - -Create a role named '`monitoring`', and add the nagios server recipe to the `run_list`. See **Monitoring Role** above for an example. - -Apply the nrpe cookbook to nodes in order to install the NRPE client - -By default the Nagios server will only monitor systems in its same environment. To change this set the `multi_environment_monitoring` attribute. See **Attributes** - -Create data bag items in the `users` data bag for each administer you would like to be able to login to the Nagios server UI. Pay special attention to the method you would like to use to authorization users (openid or htauth). See **Users** and **Atttributes** - -At this point you now have a minimally functional Nagios server, however the server will lack any service checks outside of the single Nagios Server health check. - -### defining checks - -NRPE commands are defined in recipes using the nrpe_check LWRP provider in the nrpe cookbooks. For base system monitoring such as load, ssh, memory, etc you may want to create a cookbook in your environment that defines each monitoring command via the LWRP. - -With NRPE commands created using the LWRP you will need to define Nagios services to use those commands. These services are defined using the `nagios_services` data bag and applied to roles and/or environments. See **Services** - -### enabling notifications - -You need to set `default['nagios']['notifications_enabled'] = 1` attribute on your Nagios server to enable email notifications. - -For email notifications to work an appropriate mail program package and local MTA need to be installed so that /usr/bin/mail or /bin/mail is available on the system. - -Example: - -Include [postfix cookbook](https://github.com/opscode-cookbooks/postfix) to be installed on your Nagios server node. - -Add override_attributes to your `monitoring` role: - -```ruby -# roles/monitoring.rb -name 'monitoring' -description 'Monitoring Server' -run_list( - 'recipe[nagios:default]', - 'recipe[postfix]' -) - -override_attributes( - 'nagios' => { 'notifications_enabled' => '1' }, - 'postfix' => { 'myhostname':'your_hostname', 'mydomain':'example.com' } -) - -default_attributes( - 'nagios' => { 'server_auth_method' => 'htauth' } -) -``` - -```bash -$ knife role from file monitoring.rb -``` - -## License & Authors - -- Author:: Joshua Sierles [joshua@37signals.com](mailto:joshua@37signals.com) -- Author:: Nathan Haneysmith [nathan@chef.io](mailto:nathan@chef.io) -- Author:: Joshua Timberman [joshua@chef.io](mailto:joshua@chef.io) -- Author:: Seth Chisamore [schisamo@chef.io](mailto:schisamo@chef.io) -- Author:: Tim Smith [tsmith@chef.io](mailto:tsmith@chef.io) - -```text -Copyright 2009, 37signals -Copyright 2009-2017, Chef Software, Inc -Copyright 2012, Webtrends Inc. -Copyright 2013-2014, Limelight Networks, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -``` diff --git a/cookbooks/nagios/TESTING.md b/cookbooks/nagios/TESTING.md deleted file mode 100644 index ca524abed..000000000 --- a/cookbooks/nagios/TESTING.md +++ /dev/null @@ -1,2 +0,0 @@ -Please refer to -https://github.com/chef-cookbooks/community_cookbook_documentation/blob/master/TESTING.MD diff --git a/cookbooks/nagios/attributes/config.rb b/cookbooks/nagios/attributes/config.rb deleted file mode 100644 index 4d38e7cac..000000000 --- a/cookbooks/nagios/attributes/config.rb +++ /dev/null @@ -1,171 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Attributes:: config -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# -# This class holds all nagios configuration options. -# - -default['nagios']['conf']['log_file'] = "#{node['nagios']['log_dir']}/#{node['nagios']['server']['name']}.log" -default['nagios']['conf']['cfg_dir'] = node['nagios']['config_dir'] -default['nagios']['conf']['object_cache_file'] = "#{node['nagios']['cache_dir']}/objects.cache" -default['nagios']['conf']['precached_object_file'] = "#{node['nagios']['cache_dir']}/objects.precache" -default['nagios']['conf']['resource_file'] = "#{node['nagios']['resource_dir']}/resource.cfg" -default['nagios']['conf']['temp_file'] = "#{node['nagios']['cache_dir']}/#{node['nagios']['server']['name']}.tmp" -default['nagios']['conf']['temp_path'] = '/tmp' -default['nagios']['conf']['status_file'] = "#{node['nagios']['cache_dir']}/status.dat" -default['nagios']['conf']['status_update_interval'] = '10' -default['nagios']['conf']['nagios_user'] = node['nagios']['user'] -default['nagios']['conf']['nagios_group'] = node['nagios']['group'] -default['nagios']['conf']['enable_notifications'] = '1' -default['nagios']['conf']['execute_service_checks'] = '1' -default['nagios']['conf']['accept_passive_service_checks'] = '1' -default['nagios']['conf']['execute_host_checks'] = '1' -default['nagios']['conf']['accept_passive_host_checks'] = '1' -default['nagios']['conf']['enable_event_handlers'] = '1' -default['nagios']['conf']['log_rotation_method'] = 'd' -default['nagios']['conf']['log_archive_path'] = "#{node['nagios']['log_dir']}/archives" -default['nagios']['conf']['check_external_commands'] = '1' -default['nagios']['conf']['command_check_interval'] = '-1' -default['nagios']['conf']['command_file'] = "#{node['nagios']['state_dir']}/rw/#{node['nagios']['server']['name']}.cmd" -default['nagios']['conf']['external_command_buffer_slots'] = '4096' # Deprecated, Starting with Nagios Core 4, this variable has no effect. -default['nagios']['conf']['check_for_updates'] = '0' -default['nagios']['conf']['lock_file'] = "#{node['nagios']['run_dir']}/#{node['nagios']['server']['vname']}.pid" -default['nagios']['conf']['retain_state_information'] = '1' -default['nagios']['conf']['state_retention_file'] = "#{node['nagios']['state_dir']}/retention.dat" -default['nagios']['conf']['retention_update_interval'] = '60' -default['nagios']['conf']['use_retained_program_state'] = '1' -default['nagios']['conf']['use_retained_scheduling_info'] = '1' -default['nagios']['conf']['use_syslog'] = '1' -default['nagios']['conf']['log_notifications'] = '1' -default['nagios']['conf']['log_service_retries'] = '1' -default['nagios']['conf']['log_host_retries'] = '1' -default['nagios']['conf']['log_event_handlers'] = '1' -default['nagios']['conf']['log_initial_states'] = '0' -default['nagios']['conf']['log_external_commands'] = '1' -default['nagios']['conf']['log_passive_checks'] = '1' -default['nagios']['conf']['sleep_time'] = '1' # Deprecated, Starting with Nagios Core 4, this variable has no effect. -default['nagios']['conf']['service_inter_check_delay_method'] = 's' -default['nagios']['conf']['max_service_check_spread'] = '5' -default['nagios']['conf']['service_interleave_factor'] = 's' -default['nagios']['conf']['max_concurrent_checks'] = '0' -default['nagios']['conf']['check_result_reaper_frequency'] = '10' -default['nagios']['conf']['max_check_result_reaper_time'] = '30' -default['nagios']['conf']['check_result_path'] = "#{node['nagios']['state_dir']}/spool/checkresults" -default['nagios']['conf']['max_check_result_file_age'] = '3600' -default['nagios']['conf']['host_inter_check_delay_method'] = 's' -default['nagios']['conf']['max_host_check_spread'] = '5' -default['nagios']['conf']['interval_length'] = '1' -default['nagios']['conf']['auto_reschedule_checks'] = '0' -default['nagios']['conf']['auto_rescheduling_interval'] = '30' -default['nagios']['conf']['auto_rescheduling_window'] = '180' -default['nagios']['conf']['use_aggressive_host_checking'] = '0' -default['nagios']['conf']['translate_passive_host_checks'] = '0' -default['nagios']['conf']['passive_host_checks_are_soft'] = '0' -default['nagios']['conf']['enable_predictive_host_dependency_checks'] = '1' -default['nagios']['conf']['enable_predictive_service_dependency_checks'] = '1' -default['nagios']['conf']['cached_host_check_horizon'] = '15' -default['nagios']['conf']['cached_service_check_horizon'] = '15' -default['nagios']['conf']['use_large_installation_tweaks'] = '0' -default['nagios']['conf']['enable_environment_macros'] = '1' -default['nagios']['conf']['enable_flap_detection'] = '1' -default['nagios']['conf']['low_service_flap_threshold'] = '5.0' -default['nagios']['conf']['high_service_flap_threshold'] = '20.0' -default['nagios']['conf']['low_host_flap_threshold'] = '5.0' -default['nagios']['conf']['high_host_flap_threshold'] = '20.0' -default['nagios']['conf']['soft_state_dependencies'] = '0' -default['nagios']['conf']['service_check_timeout'] = '60' -default['nagios']['conf']['host_check_timeout'] = '30' -default['nagios']['conf']['event_handler_timeout'] = '30' -default['nagios']['conf']['notification_timeout'] = '30' -default['nagios']['conf']['ocsp_timeout'] = '5' -default['nagios']['conf']['ochp_timeout'] = '5' -default['nagios']['conf']['perfdata_timeout'] = '5' -default['nagios']['conf']['obsess_over_services'] = '0' -default['nagios']['conf']['obsess_over_hosts'] = '0' -default['nagios']['conf']['process_performance_data'] = '0' -default['nagios']['conf']['check_for_orphaned_services'] = '1' -default['nagios']['conf']['check_for_orphaned_hosts'] = '1' -default['nagios']['conf']['check_service_freshness'] = '1' -default['nagios']['conf']['service_freshness_check_interval'] = '60' -default['nagios']['conf']['check_host_freshness'] = '0' -default['nagios']['conf']['host_freshness_check_interval'] = '60' -default['nagios']['conf']['additional_freshness_latency'] = '15' -default['nagios']['conf']['enable_embedded_perl'] = '1' -default['nagios']['conf']['use_embedded_perl_implicitly'] = '1' -default['nagios']['conf']['date_format'] = 'iso8601' -default['nagios']['conf']['use_timezone'] = 'UTC' -default['nagios']['conf']['illegal_object_name_chars'] = '`~!$%^&*|\'"<>?,()=' -default['nagios']['conf']['illegal_macro_output_chars'] = '`~$&|\'"<>#' -default['nagios']['conf']['use_regexp_matching'] = '0' -default['nagios']['conf']['use_true_regexp_matching'] = '0' -default['nagios']['conf']['admin_email'] = node['nagios']['sysadmin_email'] -default['nagios']['conf']['admin_pager'] = node['nagios']['sysadmin_sms_email'] -default['nagios']['conf']['event_broker_options'] = '-1' -default['nagios']['conf']['retained_host_attribute_mask'] = '0' -default['nagios']['conf']['retained_service_attribute_mask'] = '0' -default['nagios']['conf']['retained_process_host_attribute_mask'] = '0' -default['nagios']['conf']['retained_process_service_attribute_mask'] = '0' -default['nagios']['conf']['retained_contact_host_attribute_mask'] = '0' -default['nagios']['conf']['retained_contact_service_attribute_mask'] = '0' -default['nagios']['conf']['daemon_dumps_core'] = '0' -default['nagios']['conf']['debug_file'] = "#{node['nagios']['state_dir']}/#{node['nagios']['server']['name']}.debug" -default['nagios']['conf']['debug_level'] = '0' -default['nagios']['conf']['debug_verbosity'] = '1' -default['nagios']['conf']['max_debug_file_size'] = '1000000' - -default['nagios']['conf']['cfg_file'] = nil -default['nagios']['conf']['query_socket'] = nil -default['nagios']['conf']['check_workers'] = nil -default['nagios']['conf']['log_current_states'] = nil -default['nagios']['conf']['bare_update_check'] = nil -default['nagios']['conf']['global_host_event_handler'] = nil -default['nagios']['conf']['global_service_event_handler'] = nil -default['nagios']['conf']['free_child_process_memory'] = nil -default['nagios']['conf']['ocsp_command'] = nil -default['nagios']['conf']['ochp_command'] = nil -default['nagios']['conf']['host_perfdata_command'] = nil -default['nagios']['conf']['service_perfdata_command'] = nil -default['nagios']['conf']['host_perfdata_file'] = nil -default['nagios']['conf']['service_perfdata_file'] = nil -default['nagios']['conf']['host_perfdata_file_template'] = nil -default['nagios']['conf']['service_perfdata_file_template'] = nil -default['nagios']['conf']['host_perfdata_file_mode'] = nil -default['nagios']['conf']['service_perfdata_file_mode'] = nil -default['nagios']['conf']['host_perfdata_file_processing_interval'] = nil -default['nagios']['conf']['service_perfdata_file_processing_interval'] = nil -default['nagios']['conf']['host_perfdata_file_processing_command'] = nil -default['nagios']['conf']['service_perfdata_file_processing_command'] = nil -default['nagios']['conf']['broker_module'] = nil - -if node['nagios']['server']['install_method'] == 'source' || - (node['platform_family'] == 'rhel' && node['platform_version'].to_i >= 6) || - (node['platform'] == 'debian' && node['platform_version'].to_i >= 7) || - (node['platform'] == 'ubuntu' && node['platform_version'].to_f >= 14.04) - default['nagios']['conf']['allow_empty_hostgroup_assignment'] = '1' - default['nagios']['conf']['service_check_timeout_state'] = 'c' -end - -case node['platform_family'] -when 'debian' - default['nagios']['conf']['p1_file'] = "#{node['nagios']['home']}/p1.pl" -when 'rhel', 'fedora' - default['nagios']['conf']['p1_file'] = '/usr/sbin/p1.pl' -else - default['nagios']['conf']['p1_file'] = "#{node['nagios']['home']}/p1.pl" -end diff --git a/cookbooks/nagios/attributes/default.rb b/cookbooks/nagios/attributes/default.rb deleted file mode 100644 index a1b29e535..000000000 --- a/cookbooks/nagios/attributes/default.rb +++ /dev/null @@ -1,228 +0,0 @@ -# -# Author:: Seth Chisamore -# Author:: Tim Smith -# Cookbook Name:: nagios -# Attributes:: default -# -# Copyright 2011-2016, Chef Software, Inc. -# Copyright 2013-2014, Limelight Networks, Inc -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# Allow a Nagios server to monitor hosts in multiple environments. -default['nagios']['multi_environment_monitoring'] = false -default['nagios']['monitored_environments'] = [] - -default['nagios']['user'] = 'nagios' -default['nagios']['group'] = 'nagios' - -# Allow specifying which interface on clients to monitor (which IP address to monitor) -default['nagios']['monitoring_interface'] = nil - -case node['platform_family'] -when 'debian' - default['nagios']['plugin_dir'] = '/usr/lib/nagios/plugins' -when 'rhel', 'fedora' - default['nagios']['plugin_dir'] = node['kernel']['machine'] == 'i686' ? '/usr/lib/nagios/plugins' : '/usr/lib64/nagios/plugins' -else - default['nagios']['plugin_dir'] = '/usr/lib/nagios/plugins' -end - -# platform specific directories -case node['platform_family'] -when 'rhel', 'fedora' - default['nagios']['home'] = '/var/spool/nagios' - default['nagios']['conf_dir'] = '/etc/nagios' - default['nagios']['resource_dir'] = '/etc/nagios' - default['nagios']['config_dir'] = '/etc/nagios/conf.d' - default['nagios']['log_dir'] = '/var/log/nagios' - default['nagios']['cache_dir'] = '/var/log/nagios' - default['nagios']['state_dir'] = '/var/log/nagios' - default['nagios']['run_dir'] = '/var/run/nagios' - default['nagios']['docroot'] = '/usr/share/nagios/html' - default['nagios']['cgi-bin'] = '/usr/lib64/nagios/cgi-bin/' -else - default['nagios']['home'] = '/usr/lib/nagios3' - default['nagios']['conf_dir'] = '/etc/nagios3' - default['nagios']['resource_dir'] = '/etc/nagios3' - default['nagios']['config_dir'] = '/etc/nagios3/conf.d' - default['nagios']['log_dir'] = '/var/log/nagios3' - default['nagios']['cache_dir'] = '/var/cache/nagios3' - default['nagios']['state_dir'] = '/var/lib/nagios3' - default['nagios']['run_dir'] = '/var/run/nagios3' - default['nagios']['docroot'] = '/usr/share/nagios3/htdocs' - default['nagios']['cgi-bin'] = '/usr/lib/cgi-bin/nagios3' -end - -# platform specific atttributes -case node['platform_family'] -when 'debian' - default['nagios']['server']['install_method'] = 'package' - default['nagios']['server']['service_name'] = 'nagios3' - default['nagios']['server']['mail_command'] = '/usr/bin/mail' - default['nagios']['cgi-path'] = "/cgi-bin/#{node['nagios']['server']['service_name']}" -when 'rhel', 'fedora' - default['nagios']['cgi-path'] = '/nagios/cgi-bin/' - # install via source on RHEL releases less than 6, otherwise use packages - method = node['platform_family'] == 'rhel' && node['platform_version'].to_i < 6 ? 'source' : 'package' - default['nagios']['server']['install_method'] = method - default['nagios']['server']['service_name'] = 'nagios' - default['nagios']['server']['mail_command'] = '/bin/mail' -else - default['nagios']['server']['install_method'] = 'source' - default['nagios']['server']['service_name'] = 'nagios' - default['nagios']['server']['mail_command'] = '/bin/mail' -end - -# webserver configuration -default['nagios']['enable_ssl'] = false -default['nagios']['http_port'] = node['nagios']['enable_ssl'] ? '443' : '80' -default['nagios']['server_name'] = node['fqdn'] -default['nagios']['server']['server_alias'] = nil -default['nagios']['ssl_cert_file'] = "#{node['nagios']['conf_dir']}/certificates/nagios-server.pem" -default['nagios']['ssl_cert_key'] = "#{node['nagios']['conf_dir']}/certificates/nagios-server.pem" -default['nagios']['ssl_req'] = '/C=US/ST=Several/L=Locality/O=Example/OU=Operations/' \ - "CN=#{node['nagios']['server_name']}/emailAddress=ops@#{node['nagios']['server_name']}" - -# nagios server name and webserver vname. this can be changed to allow for the installation of icinga -default['nagios']['server']['name'] = 'nagios' -case node['platform_family'] -when 'rhel', 'fedora' - default['nagios']['server']['vname'] = 'nagios' -else - default['nagios']['server']['vname'] = 'nagios3' -end - -# for server from source installation -default['nagios']['server']['url'] = 'https://assets.nagios.com/downloads/nagioscore/releases/nagios-4.2.4.tar.gz' -default['nagios']['server']['checksum'] = 'b0055c475683ce50d77b1536ff0cec9abf89139adecf771601fa021ef9a20b70' -default['nagios']['server']['src_dir'] = node['nagios']['server']['url'].split('/')[-1].chomp('.tar.gz') -default['nagios']['server']['patches'] = [] -default['nagios']['server']['patch_url'] = nil - -# for server from packages installation -case node['platform_family'] -when 'rhel', 'fedora' - default['nagios']['server']['packages'] = %w(nagios nagios-plugins-nrpe) - default['nagios']['server']['install_yum-epel'] = true -else - default['nagios']['server']['packages'] = %w(nagios3 nagios-nrpe-plugin nagios-images) -end - -default['nagios']['check_external_commands'] = true -default['nagios']['default_contact_groups'] = %w(admins) -default['nagios']['default_user_name'] = nil -default['nagios']['sysadmin_email'] = 'root@localhost' -default['nagios']['sysadmin_sms_email'] = 'root@localhost' -default['nagios']['server_auth_method'] = 'htauth' -default['nagios']['server_auth_require'] = 'valid-user' -default['nagios']['users_databag'] = 'users' -default['nagios']['users_databag_group'] = 'sysadmin' -default['nagios']['services_databag'] = 'nagios_services' -default['nagios']['servicegroups_databag'] = 'nagios_servicegroups' -default['nagios']['templates_databag'] = 'nagios_templates' -default['nagios']['hosttemplates_databag'] = 'nagios_hosttemplates' -default['nagios']['eventhandlers_databag'] = 'nagios_eventhandlers' -default['nagios']['unmanagedhosts_databag'] = 'nagios_unmanagedhosts' -default['nagios']['serviceescalations_databag'] = 'nagios_serviceescalations' -default['nagios']['hostgroups_databag'] = 'nagios_hostgroups' -default['nagios']['hostescalations_databag'] = 'nagios_hostescalations' -default['nagios']['contacts_databag'] = 'nagios_contacts' -default['nagios']['contactgroups_databag'] = 'nagios_contactgroups' -default['nagios']['servicedependencies_databag'] = 'nagios_servicedependencies' -default['nagios']['timeperiods_databag'] = 'nagios_timeperiods' -default['nagios']['host_name_attribute'] = 'hostname' -default['nagios']['regexp_matching'] = 0 -default['nagios']['large_installation_tweaks'] = 0 -default['nagios']['host_template'] = 'server' - -# for cas authentication -default['nagios']['cas_login_url'] = 'https://example.com/cas/login' -default['nagios']['cas_validate_url'] = 'https://example.com/cas/serviceValidate' -default['nagios']['cas_validate_server'] = 'off' -default['nagios']['cas_root_proxy_url'] = nil - -# for apache ldap authentication -default['nagios']['ldap_bind_dn'] = nil -default['nagios']['ldap_bind_password'] = nil -default['nagios']['ldap_url'] = nil -default['nagios']['ldap_authoritative'] = nil -default['nagios']['ldap_group_attribute'] = nil -default['nagios']['ldap_group_attribute_is_dn'] = nil -default['nagios']['ldap_verify_cert'] = nil -default['nagios']['ldap_trusted_mode'] = nil -default['nagios']['ldap_trusted_global_cert'] = nil - -default['nagios']['templates'] = Mash.new - -default['nagios']['default_host']['flap_detection'] = true -default['nagios']['default_host']['process_perf_data'] = false -default['nagios']['default_host']['check_period'] = '24x7' -# Provide all interval values in seconds -default['nagios']['default_host']['check_interval'] = 15 -default['nagios']['default_host']['retry_interval'] = 15 -default['nagios']['default_host']['max_check_attempts'] = 1 -default['nagios']['default_host']['check_command'] = 'check_host_alive' -default['nagios']['default_host']['notification_interval'] = 300 -default['nagios']['default_host']['notification_options'] = 'd,u,r' -default['nagios']['default_host']['action_url'] = nil - -default['nagios']['default_service']['check_interval'] = 60 -default['nagios']['default_service']['process_perf_data'] = false -default['nagios']['default_service']['retry_interval'] = 15 -default['nagios']['default_service']['max_check_attempts'] = 3 -default['nagios']['default_service']['notification_interval'] = 1200 -default['nagios']['default_service']['flap_detection'] = true -default['nagios']['default_service']['action_url'] = nil - -default['nagios']['server']['web_server'] = 'apache' -default['nagios']['server']['nginx_dispatch'] = 'cgi' -default['nagios']['server']['stop_apache'] = false -default['nagios']['server']['normalize_hostname'] = false -default['nagios']['server']['load_default_config'] = true -default['nagios']['server']['load_databag_config'] = true -default['nagios']['server']['use_encrypted_data_bags'] = false - -default['nagios']['cgi']['show_context_help'] = 1 -default['nagios']['cgi']['authorized_for_system_information'] = '*' -default['nagios']['cgi']['authorized_for_configuration_information'] = '*' -default['nagios']['cgi']['authorized_for_system_commands'] = '*' -default['nagios']['cgi']['authorized_for_all_services'] = '*' -default['nagios']['cgi']['authorized_for_all_hosts'] = '*' -default['nagios']['cgi']['authorized_for_all_service_commands'] = '*' -default['nagios']['cgi']['authorized_for_all_host_commands'] = '*' -default['nagios']['cgi']['default_statusmap_layout'] = 5 -default['nagios']['cgi']['default_statuswrl_layout'] = 4 -default['nagios']['cgi']['result_limit'] = 100 -default['nagios']['cgi']['escape_html_tags'] = 0 -default['nagios']['cgi']['action_url_target'] = '_blank' -default['nagios']['cgi']['notes_url_target'] = '_blank' -default['nagios']['cgi']['lock_author_names'] = 1 - -default['nagios']['pagerduty']['script_url'] = 'https://raw.github.com/PagerDuty/pagerduty-nagios-pl/master/pagerduty_nagios.pl' -default['nagios']['pagerduty']['service_notification_options'] = 'w,u,c,r' -default['nagios']['pagerduty']['host_notification_options'] = 'd,r' - -# atrributes for setting broker lines -default['nagios']['brokers'] = {} - -# attribute defining tag used to exclude hosts -default['nagios']['exclude_tag_host'] = '' - -# Set the prefork module for Apache as PHP is not thread-safe -default['apache']['mpm'] = 'prefork' - -# attribute to add commands to source build -default['nagios']['source']['add_build_commands'] = ['make install-exfoliation'] -default['nagios']['allowed_ips'] = [] diff --git a/cookbooks/nagios/chefignore b/cookbooks/nagios/chefignore deleted file mode 100644 index a9769175c..000000000 --- a/cookbooks/nagios/chefignore +++ /dev/null @@ -1,102 +0,0 @@ -# Put files/directories that should be ignored in this file when uploading -# to a chef-server or supermarket. -# Lines that start with '# ' are comments. - -# OS generated files # -###################### -.DS_Store -Icon? -nohup.out -ehthumbs.db -Thumbs.db - -# SASS # -######## -.sass-cache - -# EDITORS # -########### -\#* -.#* -*~ -*.sw[a-z] -*.bak -REVISION -TAGS* -tmtags -*_flymake.* -*_flymake -*.tmproj -.project -.settings -mkmf.log - -## COMPILED ## -############## -a.out -*.o -*.pyc -*.so -*.com -*.class -*.dll -*.exe -*/rdoc/ - -# Testing # -########### -.watchr -.rspec -spec/* -spec/fixtures/* -test/* -features/* -examples/* -Guardfile -Procfile -.kitchen* -.rubocop.yml -spec/* -Rakefile -.travis.yml -.foodcritic -.codeclimate.yml - -# SCM # -####### -.git -*/.git -.gitignore -.gitmodules -.gitconfig -.gitattributes -.svn -*/.bzr/* -*/.hg/* -*/.svn/* - -# Berkshelf # -############# -Berksfile -Berksfile.lock -cookbooks/* -tmp - -# Cookbooks # -############# -CONTRIBUTING* -CHANGELOG* -TESTING* -MAINTAINERS.toml - -# Strainer # -############ -Colanderfile -Strainerfile -.colander -.strainer - -# Vagrant # -########### -.vagrant -Vagrantfile diff --git a/cookbooks/nagios/definitions/command.rb b/cookbooks/nagios/definitions/command.rb deleted file mode 100644 index 12b8c8b46..000000000 --- a/cookbooks/nagios/definitions/command.rb +++ /dev/null @@ -1,33 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : command -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_command do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Command.create(params[:name]) - o.import(params[:options]) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('command', params[:name]) - end -end diff --git a/cookbooks/nagios/definitions/contact.rb b/cookbooks/nagios/definitions/contact.rb deleted file mode 100644 index 567664477..000000000 --- a/cookbooks/nagios/definitions/contact.rb +++ /dev/null @@ -1,33 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : contact -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_contact do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Contact.create(params[:name]) - o.import(params[:options]) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('contact', params[:name]) - end -end diff --git a/cookbooks/nagios/definitions/contactgroup.rb b/cookbooks/nagios/definitions/contactgroup.rb deleted file mode 100644 index 8629b9036..000000000 --- a/cookbooks/nagios/definitions/contactgroup.rb +++ /dev/null @@ -1,33 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : contactgroup -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_contactgroup do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Contactgroup.create(params[:name]) - o.import(params[:options]) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('contactgroup', params[:name]) - end -end diff --git a/cookbooks/nagios/definitions/host.rb b/cookbooks/nagios/definitions/host.rb deleted file mode 100644 index fbd391616..000000000 --- a/cookbooks/nagios/definitions/host.rb +++ /dev/null @@ -1,33 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : host -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_host do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Host.create(params[:name]) - o.import(params[:options]) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('host', params[:name]) - end -end diff --git a/cookbooks/nagios/definitions/hostdependency.rb b/cookbooks/nagios/definitions/hostdependency.rb deleted file mode 100644 index 3bdcee6fd..000000000 --- a/cookbooks/nagios/definitions/hostdependency.rb +++ /dev/null @@ -1,33 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : hostdependency -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_hostdependency do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Hostdependency.create(params[:name]) - o.import(params[:options]) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('hostdependency', params[:name]) - end -end diff --git a/cookbooks/nagios/definitions/hostescalation.rb b/cookbooks/nagios/definitions/hostescalation.rb deleted file mode 100644 index 542b22e6a..000000000 --- a/cookbooks/nagios/definitions/hostescalation.rb +++ /dev/null @@ -1,34 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : hostescalation -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_hostescalation do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Hostescalation.new(params[:name]) - o.import(params[:options]) - Nagios.instance.push(o) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('hostescalation', params[:name]) - end -end diff --git a/cookbooks/nagios/definitions/hostgroup.rb b/cookbooks/nagios/definitions/hostgroup.rb deleted file mode 100644 index e4eae3b05..000000000 --- a/cookbooks/nagios/definitions/hostgroup.rb +++ /dev/null @@ -1,33 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : hostgroup -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_hostgroup do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Hostgroup.create(params[:name]) - o.import(params[:options]) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('hostgroup', params[:name]) - end -end diff --git a/cookbooks/nagios/definitions/nagios_conf.rb b/cookbooks/nagios/definitions/nagios_conf.rb deleted file mode 100644 index 380c2e6ab..000000000 --- a/cookbooks/nagios/definitions/nagios_conf.rb +++ /dev/null @@ -1,38 +0,0 @@ -# -# Author:: Joshua Sierles -# Author:: Joshua Timberman -# Author:: Nathan Haneysmith -# Author:: Seth Chisamore -# Cookbook Name:: nagios -# Definition:: nagios_conf -# -# Copyright 2009, 37signals -# Copyright 2009-2016, Chef Software, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -define :nagios_conf, variables: {}, config_subdir: true, source: nil do - conf_dir = params[:config_subdir] ? node['nagios']['config_dir'] : node['nagios']['conf_dir'] - params[:source] ||= "#{params[:name]}.cfg.erb" - - template "#{conf_dir}/#{params[:name]}.cfg" do - cookbook params[:cookbook] if params[:cookbook] - owner node['nagios']['user'] - group node['nagios']['group'] - source params[:source] - mode '0644' - variables params[:variables] - notifies :reload, 'service[nagios]' - backup 0 - end -end diff --git a/cookbooks/nagios/definitions/resource.rb b/cookbooks/nagios/definitions/resource.rb deleted file mode 100644 index 55f2115aa..000000000 --- a/cookbooks/nagios/definitions/resource.rb +++ /dev/null @@ -1,33 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : resource -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_resource do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Resource.create(params[:name]) - o.import(params[:options]) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('resource', params[:name]) - end -end diff --git a/cookbooks/nagios/definitions/service.rb b/cookbooks/nagios/definitions/service.rb deleted file mode 100644 index 214e15701..000000000 --- a/cookbooks/nagios/definitions/service.rb +++ /dev/null @@ -1,33 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : service -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_service do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Service.create(params[:name]) - o.import(params[:options]) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('service', params[:name]) - end -end diff --git a/cookbooks/nagios/definitions/servicedependency.rb b/cookbooks/nagios/definitions/servicedependency.rb deleted file mode 100644 index c1046aab8..000000000 --- a/cookbooks/nagios/definitions/servicedependency.rb +++ /dev/null @@ -1,33 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : servicedependency -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_servicedependency do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Servicedependency.create(params[:name]) - o.import(params[:options]) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('servicedependency', params[:name]) - end -end diff --git a/cookbooks/nagios/definitions/serviceescalation.rb b/cookbooks/nagios/definitions/serviceescalation.rb deleted file mode 100644 index f58631e00..000000000 --- a/cookbooks/nagios/definitions/serviceescalation.rb +++ /dev/null @@ -1,34 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : serviceescalation -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_serviceescalation do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Serviceescalation.new(params[:name]) - o.import(params[:options]) - Nagios.instance.push(o) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('serviceescalation', params[:name]) - end -end diff --git a/cookbooks/nagios/definitions/servicegroup.rb b/cookbooks/nagios/definitions/servicegroup.rb deleted file mode 100644 index 3e382bd6d..000000000 --- a/cookbooks/nagios/definitions/servicegroup.rb +++ /dev/null @@ -1,33 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : servicegroup -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_servicegroup do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Servicegroup.create(params[:name]) - o.import(params[:options]) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('servicegroup', params[:name]) - end -end diff --git a/cookbooks/nagios/definitions/timeperiod.rb b/cookbooks/nagios/definitions/timeperiod.rb deleted file mode 100644 index a5410576c..000000000 --- a/cookbooks/nagios/definitions/timeperiod.rb +++ /dev/null @@ -1,33 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name : nagios -# Definition : timeperiod -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -define :nagios_timeperiod do - params[:action] ||= :create - params[:options] ||= {} - - if nagios_action_create?(params[:action]) - o = Nagios::Timeperiod.create(params[:name]) - o.import(params[:options]) - end - - if nagios_action_delete?(params[:action]) - Nagios.instance.delete('timeperiod', params[:name]) - end -end diff --git a/cookbooks/nagios/libraries/base.rb b/cookbooks/nagios/libraries/base.rb deleted file mode 100644 index 47d12ea36..000000000 --- a/cookbooks/nagios/libraries/base.rb +++ /dev/null @@ -1,314 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: base -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -class Nagios - # This class it the base for all other Nagios classes. - # It provides common methods to prevent code duplication. - class Base - attr_accessor :register, - :name, - :use, - :not_modifiers - - def initialize - @add_modifiers = {} - @not_modifiers = Hash.new { |h, k| h[k] = {} } - end - - def merge!(obj) - merge_members(obj) - merge_attributes(obj) - end - - def merge_members!(obj) - merge_members(obj) - end - - def register - return @register if blank?(@name) - 0 - end - - def register=(arg) - @register = check_bool(arg) - end - - def use - default_template - end - - private - - def blank?(expr) - return true if expr.nil? - case expr - when 'String', String - return true if expr == '' - when 'Array', 'Hash', Array, Hash - return true if expr.empty? - else - false - end - false - end - - def check_bool(arg) - return 1 if arg.class == TrueClass - return 1 if arg.to_s =~ /^y|yes|true|on|1$/i - 0 - end - - def check_integer(int) - return int.to_i if int.class == String - int - end - - def check_state_option(arg, options, entry) - if options.include?(arg) - Chef::Log.debug("#{self.class} #{self} adding option #{arg} for entry #{entry}") - else - Chef::Log.fail("#{self.class} #{self} object error: Unknown option #{arg} for entry #{entry}") - raise 'Unknown option' - end - end - - def check_state_options(arg, options, entry) - if arg.class == String - check_state_options(arg.split(','), options, entry) - elsif arg.class == Array - arg.each { |a| check_state_option(a.strip, options, entry) }.join(',') - else - arg - end - end - - def check_use_and_name(default) - return nil if default.nil? - return nil if to_s == default.to_s - default - end - - def default_template - return @use unless @use.nil? - return nil if @name - case self - when Nagios::Command - check_use_and_name(Nagios.instance.default_command) - when Nagios::Contactgroup - check_use_and_name(Nagios.instance.default_contactgroup) - when Nagios::Contact - check_use_and_name(Nagios.instance.default_contact) - when Nagios::Hostgroup - check_use_and_name(Nagios.instance.default_hostgroup) - when Nagios::Host - check_use_and_name(Nagios.instance.default_host) - when Nagios::Servicegroup - check_use_and_name(Nagios.instance.default_servicegroup) - when Nagios::Service - check_use_and_name(Nagios.instance.default_service) - when Nagios::Timeperiod - check_use_and_name(Nagios.instance.default_timeperiod) - end - end - - - def get_commands(obj) - obj.map(&:to_s).join(',') - end - - def configured_option(method, option) - value = send(method) - return nil if blank?(value) - value = value.split(',') if value.is_a? String - value = value.map do |e| - (@not_modifiers[option][e] || '') + e - end.join(',') if value.is_a? Array - value - end - - def configured_options - configured = {} - config_options.each do |m, o| - next if o.nil? - value = configured_option(m, o) - next if value.nil? - configured[o] = value - end - configured - end - - def get_definition(options, group) - return nil if to_s == '*' - return nil if to_s == 'null' - d = ["define #{group} {"] - d += get_definition_options(options) - d += ['}'] - d.join("\n") - end - - def get_definition_options(options) - r = [] - longest = get_longest_option(options) - options.each do |k, v| - k = k.to_s - v = (@add_modifiers[k] || '') + v.to_s - diff = longest - k.length - r.push(k.rjust(k.length + 2) + v.rjust(v.length + diff + 2)) - end - r - end - - def get_longest_option(options) - longest = 0 - options.each do |k, _| - longest = k.length if longest < k.length - end - longest - end - - def get_members(option, object) - members = [] - case option - when String - members = object == Nagios::Command ? [option] : option.split(',') - members.map(&:strip!) - when Array - members = option - else - Chef::Log.fail("Nagios fail: Use an Array or comma seperated String for option: #{option} within #{self.class}") - raise 'Use an Array or comma seperated String for option' - end - members - end - - - def get_timeperiod(obj) - return nil if obj.nil? - return obj.to_s if obj.class == Nagios::Timeperiod - obj - end - - def merge_attributes(obj) - config_options.each do |m, _| - n = obj.send(m) - next if n.nil? - m += '=' - send(m, n) if respond_to?(m) - end - end - - def merge_members(obj) - Chef::Log.debug("Nagios debug: The method merge_members is not supported by #{obj.class}") - end - - def push(obj) - Chef::Log.debug("Nagios debug: Cannot push #{obj} into #{self.class}") - end - - def push_object(obj, hash) - return if hash.key?('null') - if obj.to_s == 'null' - hash.clear - hash[obj.to_s] = obj - elsif hash[obj.to_s].nil? - hash[obj.to_s] = obj - else - Chef::Log.debug("Nagios debug: #{self.class} already contains #{obj.class} with name: #{obj}") - end - end - - def pop_object(obj, hash) - if hash.key?(obj.to_s) - hash.delete(obj.to_s) - else - Chef::Log.debug("Nagios debug: #{self.class} does not contain #{obj.class} with name: #{obj}") - end - end - - def notification_commands(obj) - commands = [] - case obj - when Nagios::Command - commands.push(obj) - when Array - obj.each { |o| commands += notification_commands(o) } - when String - obj.split(',').each do |o| - c = Nagios::Command.new(o.strip) - n = Nagios.instance.find(c) - if c == n - Chef::Log.fail("#{self.class} fail: Cannot find command #{o} please define it first.") - raise "#{self.class} fail: Cannot find command #{o} please define it first." - else - commands.push(n) - end - end - end - commands - end - - - def hostname(name) - if Nagios.instance.normalize_hostname - name.downcase - else - name - end - end - - def update_options(hash) - return nil if blank?(hash) - update_hash_options(hash) if hash.respond_to?('each_pair') - end - - def update_hash_options(hash) - hash.each do |k, v| - push(Nagios::CustomOption.new(k.upcase, v)) if k.start_with?('_') - m = k + '=' - send(m, v) if respond_to?(m) - end - end - - def update_members(hash, option, object, remote = false) - return if blank?(hash) || hash[option].nil? - if hash[option].is_a?(String) && hash[option].start_with?('+') - @add_modifiers[option] = '+' - hash[option] = hash[option][1..-1] - end - get_members(hash[option], object).each do |member| - if member.start_with?('!') - member = member[1..-1] - @not_modifiers[option][member] = '!' - end - n = Nagios.instance.find(object.new(member)) - push(n) - n.push(self) if remote - end - end - - - def update_dependency_members(hash, option, object) - return if blank?(hash) || hash[option].nil? - get_members(hash[option], object).each do |member| - push_dependency(Nagios.instance.find(object.new(member))) - end - end - end -end diff --git a/cookbooks/nagios/libraries/command.rb b/cookbooks/nagios/libraries/command.rb deleted file mode 100644 index af3a6f6bf..000000000 --- a/cookbooks/nagios/libraries/command.rb +++ /dev/null @@ -1,91 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: command -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require_relative 'base' - -class Nagios - # - # This class holds all methods with regard to command options, - # that are used within nagios configurations. - # - class Command < Nagios::Base - attr_reader :command_name, - :timeout - attr_accessor :command_line - - def initialize(command_name) - cmd = command_name.split('!') - @command_name = cmd.shift - @timeout = nil - super() - end - - def definition - if blank?(command_line) - "# Skipping #{command_name} because command_line is missing." - else - get_definition(configured_options, 'command') - end - end - - def self.create(name) - Nagios.instance.find(Nagios::Command.new(name)) - end - - def command_line=(command_line) - param = command_timeout(command_line) - @command_line = if @timeout.nil? - command_line - elsif param.nil? - command_line + " -t #{@timeout}" - else - command_line.gsub(param, "-t #{@timeout}") - end - @command_line - end - - def import(hash) - @command_line = hash if hash.class == String - hash['command_line'] == hash['command'] unless hash['command'].nil? - update_options(hash) - end - - def to_s - command_name - end - - private - - def command_timeout(command_line) - if command_line =~ /(-t *?(\d+))/ - timeout = Regexp.last_match[2].to_i + 5 - @timeout = timeout if @timeout.nil? || timeout > @timeout - return Regexp.last_match[1] - end - nil - end - - def config_options - { - 'command_name' => 'command_name', - 'command_line' => 'command_line', - } - end - end -end diff --git a/cookbooks/nagios/libraries/contact.rb b/cookbooks/nagios/libraries/contact.rb deleted file mode 100644 index 5e9a48c76..000000000 --- a/cookbooks/nagios/libraries/contact.rb +++ /dev/null @@ -1,230 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: contact -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require_relative 'base' - -class Nagios - # - # This class holds all methods with regard to contact options, - # that are used within nagios configurations. - # - class Contact < Nagios::Base - attr_reader :contact_name, - :contactgroups, - :custom_options - - attr_accessor :alias, - :host_notifications_enabled, - :service_notifications_enabled, - :host_notification_period, - :service_notification_period, - :host_notification_options, - :service_notification_options, - :host_notification_commands, - :service_notification_commands, - :email, - :pager, - :addressx, - :can_submit_commands, - :retain_status_information, - :retain_nonstatus_information - - def initialize(contact_name) - @contact_name = contact_name - @contactgroups = {} - @host_notification_commands = [] - @service_notification_commands = [] - @custom_options = {} - super() - end - - def contactgroups_list - @contactgroups.values.map(&:to_s).sort.join(',') - end - - def definition - if email.nil? && name.nil? && pager.nil? - "# Skipping #{contact_name} because missing email/pager." - else - configured = configured_options - custom_options.each { |_, v| configured[v.to_s] = v.value } - get_definition(configured, 'contact') - end - end - - def self.create(name) - Nagios.instance.find(Nagios::Contact.new(name)) - end - - def host_notification_commands - get_commands(@host_notification_commands) - end - - def host_notification_commands=(obj) - @host_notification_commands = notification_commands(obj) - end - - def host_notification_period - get_timeperiod(@host_notification_period) - end - - def import(hash) - update_options(hash) - update_members(hash, 'contactgroups', Nagios::Contactgroup, true) - end - - def push(obj) - case obj - when Nagios::Contactgroup - push_object(obj, @contactgroups) - when Nagios::Timeperiod - @host_notification_period = obj - @service_notification_period = obj - when Nagios::CustomOption - push_object(obj, @custom_options) - end - end - - def pop(obj) - return if obj == self - case obj - when Nagios::Contactgroup - if @contactgroups.keys?(obj.to_s) - pop_object(obj, @contactgroups) - pop(self, obj) - end - when Nagios::Timeperiod - @host_notification_period = nil if obj == @host_notification_period - @service_notification_period = nil if obj == @service_notification_period - when Nagios::CustomOption - if @custom_options.keys?(obj.to_s) - pop_object(obj, @custom_options) - pop(self, obj) - end - end - end - - - def service_notification_commands - get_commands(@service_notification_commands) - end - - def service_notification_commands=(obj) - @service_notification_commands = notification_commands(obj) - end - - def service_notification_period - get_timeperiod(@service_notification_period) - end - - def to_s - contact_name - end - - # check the True/False options - # default = nil - def host_notifications_enabled=(arg) - @host_notifications_enabled = check_bool(arg) - end - - def service_notifications_enabled=(arg) - @service_notifications_enabled = check_bool(arg) - end - - def can_submit_commands=(arg) - @can_submit_commands = check_bool(arg) - end - - def retain_status_information=(arg) - @retain_status_information = check_bool(arg) - end - - def retain_nonstatus_information=(arg) - @retain_nonstatus_information = check_bool(arg) - end - - # check other options - # - # host_notification_options - # This directive is used to define the host states for which notifications - # can be sent out to this contact. - # Valid options are a combination of one or more of the following: - # d = notify on DOWN host states, - # u = notify on UNREACHABLE host states, - # r = notify on host recoveries (UP states), - # f = notify when the host starts and stops flapping, - # s = send notifications when host or service scheduled downtime starts and ends. - # - # If you specify n (none) as an option, the contact will not receive any type of - # host notifications. - def host_notification_options=(arg) - @host_notification_options = check_state_options( - arg, %w(d u r f s n), 'host_notification_options') - end - - # service_notification_options - # This directive is used to define the service states for which notifications - # can be sent out to this contact. - # Valid options are a combination of one or more of the following: - # w = notify on WARNING service states, - # u = notify on UNKNOWN service states, - # c = notify on CRITICAL service states, - # r = notify on service recoveries (OK states), - # f = notify when the service starts and stops flapping. - # - # If you specify n (none) as an option, the contact will not receive any type of - # service notifications. - def service_notification_options=(arg) - @service_notification_options = check_state_options( - arg, %w(w u c r f n), 'service_notification_options') - end - - private - - def config_options - { - 'name' => 'name', - 'use' => 'use', - 'contact_name' => 'contact_name', - 'contactgroups_list' => 'contactgroups', - 'alias' => 'alias', - 'host_notifications_enabled' => 'host_notifications_enabled', - 'service_notifications_enabled' => 'service_notifications_enabled', - 'host_notification_period' => 'host_notification_period', - 'service_notification_period' => 'service_notification_period', - 'host_notification_options' => 'host_notification_options', - 'service_notification_options' => 'service_notification_options', - 'host_notification_commands' => 'host_notification_commands', - 'service_notification_commands' => 'service_notification_commands', - 'email' => 'email', - 'pager' => 'pager', - 'addressx' => 'addressx', - 'can_submit_commands' => 'can_submit_commands', - 'retain_status_information' => 'retain_status_information', - 'retain_nonstatus_information' => 'retain_nonstatus_information', - 'register' => 'register', - } - end - - def merge_members(obj) - obj.contactgroups.each { |m| push(m) } - obj.custom_options.each { |_, m| push(m) } - end - end -end diff --git a/cookbooks/nagios/libraries/contactgroup.rb b/cookbooks/nagios/libraries/contactgroup.rb deleted file mode 100644 index 677727c2e..000000000 --- a/cookbooks/nagios/libraries/contactgroup.rb +++ /dev/null @@ -1,112 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: contactgroup -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require_relative 'nagios' - -class Nagios - # - # This class holds all methods with regard to contactgroup options, - # that are used within nagios configurations. - # - class Contactgroup < Nagios::Base - attr_reader :contactgroup_name, - :members, - :contactgroup_members - - attr_accessor :alias - - def initialize(contactgroup_name) - @contactgroup_name = contactgroup_name - @members = {} - @contactgroup_members = {} - super() - end - - def contactgroup_members_list - @contactgroup_members.values.map(&:to_s).sort.join(',') - end - - def self.create(name) - Nagios.instance.find(Nagios::Contactgroup.new(name)) - end - - def definition - get_definition(configured_options, 'contactgroup') - end - - def import(hash) - update_options(hash) - update_members(hash, 'members', Nagios::Contact, true) - update_members(hash, 'contactgroups_members', Nagios::Contactgroup, true) - end - - def members_list - @members.values.map(&:to_s).sort.join(',') - end - - def push(obj) - case obj - when Nagios::Contact - push_object(obj, @members) - when Nagios::Contactgroup - push_object(obj, @contactgroup_members) - end - end - - def pop(obj) - return if obj == self - case obj - when Nagios::Contact - if @members.keys?(obj.to_s) - pop_object(obj, @members) - pop(self, obj) - end - when Nagios::Contactgroup - if @contactgroups_members.keys?(obj.to_s) - pop_object(obj, @contactgroup_members) - pop(self, obj) - end - end - end - - - def to_s - contactgroup_name - end - - private - - def config_options - { - 'name' => 'name', - 'use' => 'use', - 'contactgroup_name' => 'contactgroup_name', - 'members_list' => 'members', - 'contactgroup_members_list' => 'contactgroup_members', - 'alias' => 'alias', - 'register' => 'register', - } - end - - def merge_members(obj) - obj.members.each { |m| push(m) } - obj.contactgroup_members.each { |m| push(m) } - end - end -end diff --git a/cookbooks/nagios/libraries/custom_option.rb b/cookbooks/nagios/libraries/custom_option.rb deleted file mode 100644 index 6109f33d6..000000000 --- a/cookbooks/nagios/libraries/custom_option.rb +++ /dev/null @@ -1,36 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: custom_option -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -class Nagios - # - # This class holds all methods with regard to custom_options - # - class CustomOption - attr_reader :value - - def initialize(option, value) - @option = option - @value = value - end - - def to_s - @option - end - end -end diff --git a/cookbooks/nagios/libraries/data_bag_helper.rb b/cookbooks/nagios/libraries/data_bag_helper.rb deleted file mode 100644 index 888f3de75..000000000 --- a/cookbooks/nagios/libraries/data_bag_helper.rb +++ /dev/null @@ -1,23 +0,0 @@ -require 'chef/search/query' - -# simplified access to databags in the nagios cookbook -class NagiosDataBags - attr_accessor :bag_list - - def initialize(bag_list = Chef::DataBag.list) - @bag_list = bag_list - end - - # Returns an array of data bag items or an empty array - # Avoids unecessary calls to search by checking against - # the list of known data bags. - def get(bag_name) - results = [] - if @bag_list.include?(bag_name) - Chef::Search::Query.new.search(bag_name.to_s, '*:*') { |rows| results << rows } - else - Chef::Log.info "The #{bag_name} data bag does not exist." - end - results - end -end diff --git a/cookbooks/nagios/libraries/default.rb b/cookbooks/nagios/libraries/default.rb deleted file mode 100644 index a40a9b7d6..000000000 --- a/cookbooks/nagios/libraries/default.rb +++ /dev/null @@ -1,90 +0,0 @@ -# -# Author:: Joshua Sierles -# Author:: Tim Smith -# Cookbook Name:: nagios -# Library:: default -# -# Copyright 2009, 37signals -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -def nagios_boolean(true_or_false) - true_or_false ? '1' : '0' -end - -def nagios_interval(seconds) - if seconds.to_i == 0 - raise ArgumentError, 'Specified nagios interval of 0 seconds is not allowed' - end - interval = seconds - if node['nagios']['conf']['interval_length'].to_i != 1 - interval = seconds.to_f / node['nagios']['conf']['interval_length'] - end - interval -end - -def nagios_array(exp) - return [] if exp.nil? - case exp - when String - [exp] - else - exp - end -end - -def nagios_action_delete?(action) - if action.is_a?(Symbol) - return true if action == :delete || action == :remove - elsif action.is_a?(Array) - return true if action.include?(:delete) || action.include?(:remove) - else - false - end -end - -def nagios_action_create?(action) - if action.is_a?(Symbol) - return true if action == :create || action == :add - elsif action.is_a?(Array) - return true if action.include?(:create) || action.include?(:add) - else - false - end -end - -def nagios_attr(name) - node['nagios'][name] -end - -# decide whether to use internal or external IP addresses for this node -# if the nagios server is not in the cloud, always use public IP addresses for cloud nodes. -# if the nagios server is in the cloud, use private IP addresses for any -# cloud servers in the same cloud, public IPs for servers in other clouds -# (where other is defined by node['cloud']['provider']) -# if the cloud IP is nil then use the standard IP address attribute. This is a work around -# for OHAI incorrectly identifying systems on Cisco hardware as being in Rackspace -def ip_to_monitor(monitored_host, server_host = node) - # if interface to monitor is specified implicitly use that - if node['nagios']['monitoring_interface'] && node['network']["ipaddress_#{node['nagios']['monitoring_interface']}"] - node['network']["ipaddress_#{node['nagios']['monitoring_interface']}"] - # if server is not in the cloud and the monitored host is - elsif server_host['cloud'].nil? && monitored_host['cloud'] - monitored_host['cloud']['public_ipv4'].include?('.') ? monitored_host['cloud']['public_ipv4'] : monitored_host['ipaddress'] - # if server host is in the cloud and the monitored node is as well, but they are not on the same provider - elsif server_host['cloud'] && monitored_host['cloud'] && monitored_host['cloud']['provider'] != server_host['cloud']['provider'] - monitored_host['cloud']['public_ipv4'].include?('.') ? monitored_host['cloud']['public_ipv4'] : monitored_host['ipaddress'] - else - monitored_host['ipaddress'] - end -end diff --git a/cookbooks/nagios/libraries/host.rb b/cookbooks/nagios/libraries/host.rb deleted file mode 100644 index 955aff2ae..000000000 --- a/cookbooks/nagios/libraries/host.rb +++ /dev/null @@ -1,412 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: host -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -require_relative 'base' - -class Nagios - # - # This class holds all methods with regard to host options, - # that are used within nagios configurations. - # - class Host < Nagios::Base - attr_reader :host_name, - :parents, - :hostgroups, - :contacts, - :contact_groups, - :custom_options - - attr_accessor :alias, - :display_name, - :address, - :check_command, - :initial_state, - :max_check_attempts, - :check_interval, - :retry_interval, - :active_checks_enabled, - :passive_checks_enabled, - :check_period, - :obsess_over_host, - :check_freshness, - :freshness_threshold, - :event_handler, - :event_handler_enabled, - :low_flap_threshold, - :high_flap_threshold, - :flap_detection_enabled, - :flap_detection_options, - :process_perf_data, - :retain_status_information, - :retain_nonstatus_information, - :notification_interval, - :first_notification_delay, - :notification_period, - :notification_options, - :notifications_enabled, - :stalking_options, - :notes, - :notes_url, - :action_url, - :icon_image, - :icon_image_alt, - :vrml_image, - :statusmap_image, - :_2d_coords, - :_3d_coords - - def initialize(host_name) - @host_name = hostname(host_name) - @hostgroups = {} - @parents = {} - @contacts = {} - @contact_groups = {} - @check_period = nil - @notification_period = nil - @custom_options = {} - super() - end - - def check_period - get_timeperiod(@check_period) - end - - # contacts - # This is a list of the short names of the contacts that should be notified - # whenever there are problems (or recoveries) with this host. - # Multiple contacts should be separated by commas. - # Useful if you want notifications to go to just a few people and don't want - # to configure contact groups. - # You must specify at least one contact or contact group in each host definition. - def contacts_list - @contacts.values.map(&:to_s).sort.join(',') - end - - # contact_groups - # This is a list of the short names of the contact groups that should be notified - # whenever there are problems (or recoveries) with this host. - # Multiple contact groups should be separated by commas. - # You must specify at least one contact or contact group in each host definition. - def contact_groups_list - @contact_groups.values.map(&:to_s).sort.join(',') - end - - def definition - configured = configured_options - custom_options.each { |_, v| configured[v.to_s] = v.value } - get_definition(configured, 'host') - end - - # hostgroups - # This directive is used to identify the short name(s) of the hostgroup(s) - # that the host belongs to. Multiple hostgroups should be separated by commas. - # This directive may be used as an alternative to (or in addition to) - # using the members directive in hostgroup definitions. - def hostgroups_list - @hostgroups.values.map(&:to_s).sort.join(',') - end - - def import(hash) - update_options(hash) - update_members(hash, 'parents', Nagios::Host) - update_members(hash, 'contacts', Nagios::Contact) - update_members(hash, 'contact_groups', Nagios::Contactgroup) - update_members(hash, 'hostgroups', Nagios::Hostgroup, true) - end - - def notification_period - get_timeperiod(@notification_period) - end - - def notifications - @notifications_enabled - end - - def notifications=(arg) - @notifications_enabled = check_bool(arg) - end - - # parents - # This directive is used to define a comma-delimited list of short names of - # the "parent" hosts for this particular host. Parent hosts are typically routers, - # switches, firewalls, etc. that lie between the monitoring host and a remote hosts. - # A router, switch, etc. which is closest to the remote host is considered - # to be that host's "parent". - # If this host is on the same network segment as the host doing the monitoring - # (without any intermediate routers, etc.) the host is considered to be on the local - # network and will not have a parent host. - def parents_list - @parents.values.map(&:to_s).sort.join(',') - end - - def push(obj) - case obj - when Nagios::Hostgroup - push_object(obj, @hostgroups) - when Nagios::Host - push_object(obj, @parents) - when Nagios::Contact - push_object(obj, @contacts) - when Nagios::Contactgroup - push_object(obj, @contact_groups) - when Nagios::Timeperiod - @check_period = obj - @notification_period = obj - when Nagios::CustomOption - push_object(obj, @custom_options) - end - end - - def pop(obj) - return if obj == self - case obj - when Nagios::Hostgroup - if @hostgroups.key?(obj.to_s) - pop_object(obj, @hostgroups) - obj.pop(self) - end - when Nagios::Host - if @parents.key?(obj.to_s) - pop_object(obj, @parents) - obj.pop(self) - end - when Nagios::Contact - if @contacts.keys?(obj.to_s) - pop_object(obj, @contacts) - obj.pop(self) - end - when Nagios::Contactgroup - if @contact_groups.keys?(obj.to_s) - pop_object(obj, @contact_groups) - obj.pop(self) - end - when Nagios::Timeperiod - @check_period = nil if @check_period == obj - @notification_period = nil if @notification_period == obj - when Nagios::CustomOption - if @custom_options.keys?(obj.to_s) - pop_object(obj, @custom_options) - obj.pop(self) - end - end - end - - - def self.create(name) - Nagios.instance.find(Nagios::Host.new(name)) - end - - def to_s - host_name - end - - # check the integer options - # default = nil - - def max_check_attempts=(int) - @max_check_attempts = check_integer(int) - end - - def check_interval=(int) - @check_interval = check_integer(int) - end - - def retry_interval=(int) - @retry_interval = check_integer(int) - end - - def freshness_threshold=(int) - @freshness_threshold = check_integer(int) - end - - def low_flap_threshold=(int) - @low_flap_threshold = check_integer(int) - end - - def high_flap_threshold=(int) - @high_flap_threshold = check_integer(int) - end - - def notification_interval=(int) - @notification_interval = check_integer(int) - end - - def first_notification_delay=(int) - @first_notification_delay = check_integer(int) - end - - # check the True/False options - # default = nil - - def active_checks_enabled=(arg) - @active_checks_enabled = check_bool(arg) - end - - def passive_checks_enabled=(arg) - @passive_checks_enabled = check_bool(arg) - end - - def obsess_over_host=(arg) - @obsess_over_host = check_bool(arg) - end - - def check_freshness=(arg) - @check_freshness = check_bool(arg) - end - - def event_handler_enabled=(arg) - @event_handler_enabled = check_bool(arg) - end - - def flap_detection_enabled=(arg) - @flap_detection_enabled = check_bool(arg) - end - - def process_perf_data=(arg) - @process_perf_data = check_bool(arg) - end - - def retain_status_information=(arg) - @retain_status_information = check_bool(arg) - end - - def retain_nonstatus_information=(arg) - @retain_nonstatus_information = check_bool(arg) - end - - def notifications_enabled=(arg) - @notifications_enabled = check_bool(arg) - end - - # check other options - - # initial_state - # By default Nagios will assume that all hosts are in UP states when it starts. - # You can override the initial state for a host by using this directive. - # Valid options are: - # o = UP, - # d = DOWN, - # u = UNREACHABLE. - def initial_state=(arg) - @initial_state = check_state_options(arg, %w(o d u), 'initail_state') - end - - # flap_detection_options - # This directive is used to determine what host states the flap detection logic will use for this host. - # Valid options are a combination of one or more of the following: - # o = UP states, - # d = DOWN states, - # u = UNREACHABLE states. - def flap_detection_options=(arg) - @flap_detection_options = check_state_options(arg, %w(o d u), 'flap_detection_options') - end - - # stalking_options - # This directive determines which host states "stalking" is enabled for. - # Valid options are a combination of one or more of the following: - # o = stalk on UP states, - # d = stalk on DOWN states, - # u = stalk on UNREACHABLE states. - def stalking_options=(arg) - @stalking_options = check_state_options(arg, %w(o d u), 'stalking_options') - end - - # notification_options - # This directive is used to determine when notifications for the host should be sent out. - # Valid options are a combination of one or more of the following: - # d = send notifications on a DOWN state, - # u = send notifications on an UNREACHABLE state, - # r = send notifications on recoveries (OK state), - # f = send notifications when the host starts and stops flapping - # s = send notifications when scheduled downtime starts and ends. - # If you specify n (none) as an option, no host notifications will be sent out. - # If you do not specify any notification options, Nagios will assume that you want notifications - # to be sent out for all possible states. - # Example: If you specify d,r in this field, notifications will only be sent out when the host - # goes DOWN and when it recovers from a DOWN state. - - def notification_options=(arg) - @notification_options = check_state_options(arg, %w(d u r f s n), 'notification_options') - end - - private - - def config_options - { - 'name' => 'name', - 'use' => 'use', - 'host_name' => 'host_name', - 'hostgroups_list' => 'hostgroups', - 'alias' => 'alias', - 'display_name' => 'display_name', - 'address' => 'address', - 'parents_list' => 'parents', - 'check_command' => 'check_command', - 'initial_state' => 'initial_state', - 'max_check_attempts' => 'max_check_attempts', - 'check_interval' => 'check_interval', - 'retry_interval' => 'retry_interval', - 'active_checks_enabled' => 'active_checks_enabled', - 'passive_checks_enabled' => 'passive_checks_enabled', - 'check_period' => 'check_period', - 'obsess_over_host' => 'obsess_over_host', - 'check_freshness' => 'check_freshness', - 'freshness_threshold' => 'freshness_threshold', - 'event_handler' => 'event_handler', - 'event_handler_enabled' => 'event_handler_enabled', - 'low_flap_threshold' => 'low_flap_threshold', - 'high_flap_threshold' => 'high_flap_threshold', - 'flap_detection_enabled' => 'flap_detection_enabled', - 'flap_detection_options' => 'flap_detection_options', - 'process_perf_data' => 'process_perf_data', - 'retain_status_information' => 'retain_status_information', - 'retain_nonstatus_information' => 'retain_nonstatus_information', - 'contacts_list' => 'contacts', - 'contact_groups_list' => 'contact_groups', - 'notification_interval' => 'notification_interval', - 'first_notification_delay' => 'first_notification_delay', - 'notification_period' => 'notification_period', - 'notification_options' => 'notification_options', - 'notifications_enabled' => 'notifications_enabled', - 'notifications' => nil, - 'stalking_options' => 'stalking_options', - 'notes' => 'notes', - 'notes_url' => 'notes_url', - 'action_url' => 'action_url', - 'icon_image' => 'icon_image', - 'icon_image_alt' => 'icon_image_alt', - 'vrml_image' => 'vrml_image', - 'statusmap_image' => 'statusmap_image', - '_2d_coords' => '2d_coords', - '_3d_coords' => '3d_coords', - 'register' => 'register', - } - end - - - def merge_members(obj) - obj.parents.each { |m| push(m) } - obj.contacts.each { |m| push(m) } - obj.contact_groups.each { |m| push(m) } - obj.hostgroups.each { |m| push(m) } - obj.custom_options.each { |_, m| push(m) } - end - end -end diff --git a/cookbooks/nagios/libraries/hostdependency.rb b/cookbooks/nagios/libraries/hostdependency.rb deleted file mode 100644 index 5509ecd08..000000000 --- a/cookbooks/nagios/libraries/hostdependency.rb +++ /dev/null @@ -1,181 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: hostdependency -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require_relative 'base' - -class Nagios - # - # This class holds all methods with regard to hostdependency options, - # that are used within nagios configurations. - # - class Hostdependency < Nagios::Base - attr_reader :dependent_name, - :dependency_period, - :dependent_host_name, - :dependent_hostgroup_name, - :host_name, - :hostgroup_name - - attr_accessor :inherits_parent, - :execution_failure_criteria, - :notification_failure_criteria - - def initialize(name) - @dependent_name = name - @host_name = {} - @hostgroup_name = {} - @dependent_host_name = {} - @dependent_hostgroup_name = {} - super() - end - - def definition - get_definition(configured_options, 'hostdependency') - end - - def dependent_host_name_list - @dependent_host_name.values.map(&:to_s).sort.join(',') - end - - def dependent_hostgroup_name_list - @dependent_hostgroup_name.values.map(&:to_s).sort.join(',') - end - - def host_name_list - @host_name.values.map(&:to_s).sort.join(',') - end - - def hostgroup_name_list - @hostgroup_name.values.map(&:to_s).sort.join(',') - end - - def import(hash) - update_options(hash) - update_members(hash, 'host_name', Nagios::Host) - update_members(hash, 'hostgroup_name', Nagios::Hostgroup) - update_dependency_members(hash, 'dependent_host_name', Nagios::Host) - update_dependency_members(hash, 'dependent_hostgroup_name', Nagios::Hostgroup) - end - - def push(obj) - case obj - when Nagios::Host - push_object(obj, @host_name) - when Nagios::Hostgroup - push_object(obj, @hostgroup_name) - when Nagios::Timeperiod - @dependency_period = obj - end - end - - def push_dependency(obj) - case obj - when Nagios::Host - push_object(obj, @dependent_host_name) - when Nagios::Hostgroup - push_object(obj, @dependent_hostgroup_name) - end - end - - def pop(obj) - return if obj == self - case obj - when Nagios::Host - if @host_name.keys?(obj.to_s) - pop_object(obj, @host_name) - pop(self, obj) - end - when Nagios::Hostgroup - if @hostgroup_name.keys?(obj.to_s) - pop_object(obj, @hostgroup_name) - pop(self, obj) - end - when Nagios::Timeperiod - @dependency_period = nil if @dependency_period == obj - end - end - - def pop_dependency(obj) - return if obj == self - case obj - when Nagios::Host - if @dependent_host_name.keys?(obj.to_s) - pop_object(obj, @dependent_host_name) - pop(self, obj) - end - when Nagios::Hostgroup - if @dependent_hostgroup_name.keys?(obj.to_s) - pop_object(obj, @dependent_hostgroup_name) - pop(self, obj) - end - end - end - - - def self.create(name) - Nagios.instance.find(Nagios::Hostdependency.new(name)) - end - - def to_s - dependent_name - end - - # check the True/False options - # default = nil - - def inherits_parent=(arg) - @inherits_parent = check_bool(arg) - end - - # check other options - - def execution_failure_criteria=(arg) - @execution_failure_criteria = check_state_options(arg, %w(o d u p n), 'execution_failure_criteria') - end - - def notification_failure_criteria=(arg) - @notification_failure_criteria = check_state_options(arg, %w(o d u p n), 'notification_failure_criteria') - end - - private - - def config_options - { - 'dependent_name' => nil, - 'dependency_period' => 'dependency_period', - 'dependent_host_name_list' => 'dependent_host_name', - 'dependent_hostgroup_name_list' => 'dependent_hostgroup_name', - 'host_name_list' => 'host_name', - 'hostgroup_name_list' => 'hostgroup_name', - 'inherits_parent' => 'inherits_parent', - 'execution_failure_criteria' => 'execution_failure_criteria', - 'notification_failure_criteria' => 'notification_failure_criteria', - } - end - - - def merge_members(obj) - obj.host_name.each { |m| push(m) } - obj.hostgroup_name.each { |m| push(m) } - obj.dependent_host_name.each { |m| push_dependency(m) } - obj.dependent_hostgroup_name.each { |m| push_dependency(m) } - end - end -end - diff --git a/cookbooks/nagios/libraries/hostescalation.rb b/cookbooks/nagios/libraries/hostescalation.rb deleted file mode 100644 index 5538d418e..000000000 --- a/cookbooks/nagios/libraries/hostescalation.rb +++ /dev/null @@ -1,173 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: hostescalation -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require_relative 'base' - -class Nagios - # - # This class holds all methods with regard to hostescalation options, - # that are used within nagios configurations. - # - class Hostescalation < Nagios::Base - attr_reader :host_description, - :host_name, - :hostgroup_name, - :contacts, - :contact_groups - - attr_accessor :first_notification, - :last_notification, - :notification_interval, - :escalation_options, - :escalation_period - - def initialize(name) - @host_description = name - @contacts = {} - @contact_groups = {} - @host_name = {} - @hostgroup_name = {} - super() - end - - def definition - get_definition(configured_options, 'hostescalation') - end - - def contacts_list - @contacts.values.map(&:to_s).sort.join(',') - end - - def contact_groups_list - @contact_groups.values.map(&:to_s).sort.join(',') - end - - def host_name_list - @host_name.values.map(&:to_s).sort.join(',') - end - - def hostgroup_name_list - @hostgroup_name.values.map(&:to_s).sort.join(',') - end - - def import(hash) - update_options(hash) - update_members(hash, 'contacts', Nagios::Contact) - update_members(hash, 'contact_groups', Nagios::Contactgroup) - update_members(hash, 'host_name', Nagios::Host) - update_members(hash, 'hostgroup_name', Nagios::Hostgroup) - end - - def push(obj) - case obj - when Nagios::Host - push_object(obj, @host_name) - when Nagios::Hostgroup - push_object(obj, @hostgroup_name) - when Nagios::Contact - push_object(obj, @contacts) - when Nagios::Contactgroup - push_object(obj, @contact_groups) - when Nagios::Timeperiod - @escalation_period = obj - end - end - - def pop(obj) - return if obj == self - case obj - when Nagios::Host - if @host_name.keys?(obj.to_s) - pop_object(obj, @host_name) - pop(self, obj) - end - when Nagios::Hostgroup - if @hostgroup_name.keys?(obj.to_s) - pop_object(obj, @hostgroup_name) - pop(self, obj) - end - when Nagios::Contact - if @contacts.keys?(obj.to_s) - pop_object(obj, @contacts) - pop(self, obj) - end - when Nagios::Contactgroup - if @contact_groups.keys?(obj.to_s) - pop_object(obj, @contact_groups) - pop(self, obj) - end - when Nagios::Timeperiod - @escalation_period = nil if @escalation_period == obj - end - end - - - def to_s - host_description - end - - # check the integer options - # default = nil - def first_notification=(int) - @first_notification = check_integer(int) - end - - def last_notification=(int) - @last_notification = check_integer(int) - end - - def notification_interval=(int) - @notification_interval = check_integer(int) - end - - # check other options - def escalation_options=(arg) - @escalation_options = check_state_options(arg, %w(d u r), 'escalation_options') - end - - private - - def config_options - { - 'name' => 'name', - 'use' => 'use', - 'host_description' => nil, - 'contacts_list' => 'contacts', - 'contact_groups_list' => 'contact_groups', - 'escalation_period' => 'escalation_period', - 'host_name_list' => 'host_name', - 'hostgroup_name_list' => 'hostgroup_name', - 'escalation_options' => 'escalation_options', - 'first_notification' => 'first_notification', - 'last_notification' => 'last_notification', - 'notification_interval' => 'notification_interval', - 'register' => 'register', - } - end - - - def merge_members(obj) - obj.contacts.each { |m| push(m) } - obj.host_name.each { |m| push(m) } - obj.contact_groups.each { |m| push(m) } - obj.hostgroup_name.each { |m| push(m) } - end - end -end - diff --git a/cookbooks/nagios/libraries/hostgroup.rb b/cookbooks/nagios/libraries/hostgroup.rb deleted file mode 100644 index a1f2ed57b..000000000 --- a/cookbooks/nagios/libraries/hostgroup.rb +++ /dev/null @@ -1,119 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: hostgroup -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require_relative 'base' - -class Nagios - # - # This class holds all methods with regard to hostgroup options, - # that are used within nagios configurations. - # - class Hostgroup < Nagios::Base - attr_reader :hostgroup_name, - :members, - :hostgroup_members - - attr_accessor :alias, - :notes, - :notes_url, - :action_url - - def initialize(hostgroup_name) - @hostgroup_name = hostgroup_name - @members = {} - @hostgroup_members = {} - super() - end - - def definition - get_definition(configured_options, 'hostgroup') - end - - def hostgroup_members_list - @hostgroup_members.values.map(&:to_s).sort.join(',') - end - - def import(hash) - update_options(hash) - update_members(hash, 'members', Nagios::Host, true) - update_members(hash, 'hostgroups_members', Nagios::Hostgroup, true) - end - - def members_list - @members.values.map(&:to_s).sort.join(',') - end - - def push(obj) - case obj - when Nagios::Host - push_object(obj, @members) - when Nagios::Hostgroup - push_object(obj, @hostgroup_members) - end - end - - def pop(obj) - return if obj == self - case obj - when Nagios::Host - if @members.key?(obj.to_s) - pop_object(obj, @members) - obj.pop(obj) - end - when Nagios::Hostgroup - if @hostgroups_members.key?(obj.to_s) - pop_object(obj, @hostgroup_members) - obj.pop(obj) - end - end - end - - - def self.create(name) - Nagios.instance.find(Nagios::Hostgroup.new(name)) - end - - def to_s - hostgroup_name - end - - private - - def config_options - { - 'name' => 'name', - 'use' => 'use', - 'hostgroup_name' => 'hostgroup_name', - 'members_list' => 'members', - 'hostgroup_members_list' => 'hostgroup_members', - 'alias' => 'alias', - 'notes' => 'notes', - 'notes_url' => 'notes_url', - 'action_url' => 'action_url', - 'register' => 'register', - } - end - - - def merge_members(obj) - obj.members.each { |m| push(m) } - obj.hostgroup_members.each { |m| push(m) } - end - end -end diff --git a/cookbooks/nagios/libraries/nagios.rb b/cookbooks/nagios/libraries/nagios.rb deleted file mode 100644 index 075546be4..000000000 --- a/cookbooks/nagios/libraries/nagios.rb +++ /dev/null @@ -1,282 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: nagios -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# -# This class holds all methods with regard to the nagios model. -# -class Nagios - attr_reader :commands, - :contactgroups, - :contacts, - :hostgroups, - :hosts, - :servicegroups, - :services, - :timeperiods, - :hostdependencies, - :hostescalations, - :servicedependencies, - :serviceescalations, - :resources - - attr_accessor :host_name_attribute, - :normalize_hostname, - :default_command, - :default_contactgroup, - :default_contact, - :default_hostgroup, - :default_host, - :default_servicegroup, - :default_service, - :default_timeperiod - - def initialize - @commands = {} - @contactgroups = {} - @contacts = {} - @hostgroups = {} - @hosts = {} - @servicegroups = {} - @services = {} - @timeperiods = {} - @hostdependencies = {} - @hostescalations = [] - @servicedependencies = {} - @serviceescalations = [] - @resources = {} - @host_name_attribute = 'hostname' - @normalize_hostname = false - end - - - def commands - Hash[@commands.sort] - end - - def contactgroups - Hash[@contactgroups.sort] - end - - def contacts - Hash[@contacts.sort] - end - - def delete(hash, key) - case hash - when 'command' - @commands.delete(key) - when 'contactgroup' - @contactgroups.delete(key) - when 'contact' - @contacts.delete(key) - when 'hostgroup' - @hostgroups.delete(key) - when 'host' - @hosts.delete(key) - when 'servicegroup' - @servicegroups.delete(key) - when 'service' - @services.delete(key) - when 'timeperiod' - @timeperiods.delete(key) - when 'hostdependency' - @hostdependencies.delete(key) - when 'hostescalation' - @hostescalations.delete(key) - when 'servicedependency' - @servicedependencies.delete(key) - when 'serviceescalation' - @serviceescalations.delete(key) - when 'resource' - @resources.delete(key) - end - end - - - def find(obj) - case obj - when Nagios::Command - find_object(obj, @commands) - when Nagios::Contact - find_object(obj, @contacts) - when Nagios::Contactgroup - find_object(obj, @contactgroups) - when Nagios::Host - find_object(obj, @hosts) - when Nagios::Hostgroup - find_object(obj, @hostgroups) - when Nagios::Service - find_object(obj, @services) - when Nagios::Servicegroup - find_object(obj, @servicegroups) - when Nagios::Timeperiod - find_object(obj, @timeperiods) - when Nagios::Hostdependency - find_object(obj, @hostdependencies) - when Nagios::Servicedependency - find_object(obj, @servicedependencies) - when Nagios::Resource - find_object(obj, @resources) - end - end - - - def hosts - Hash[@hosts.sort] - end - - def hostdependencies - Hash[@hostdependencies.sort] - end - - def hostgroups - Hash[@hostgroups.sort] - end - - def normalize_hostname=(expr) - @normalize_hostname = (expr == true || !(expr =~ /y|yes|true|1/).nil?) - end - - def push(obj) - case obj - when Chef::Node - push_node(obj) - when Nagios::Command - push_object(obj) - when Nagios::Contact - push_object(obj) - when Nagios::Contactgroup - push_object(obj) - when Nagios::Host - push_object(obj) - when Nagios::Hostgroup - push_object(obj) - when Nagios::Service - push_object(obj) - when Nagios::Servicegroup - push_object(obj) - when Nagios::Timeperiod - push_object(obj) - when Nagios::Hostdependency - push_object(obj) - when Nagios::Hostescalation - @hostescalations.push(obj) - when Nagios::Servicedependency - push_object(obj) - when Nagios::Serviceescalation - @serviceescalations.push(obj) - when Nagios::Resource - push_object(obj) - else - Chef::Log.fail("Nagios error: Pushing unknown object: #{obj.class} into Nagios.instance") - raise - end - end - - - def timeperiods - Hash[@timeperiods.sort] - end - - def resources - Hash[@resources.sort] - end - - def self.instance - @instance ||= Nagios.new - end - - def services - Hash[@services.sort] - end - - def servicedependencies - Hash[@servicedependencies.sort] - end - - def servicegroups - Hash[@servicegroups.sort] - end - - private - - def blank?(expr) - return true if expr.nil? - case expr - when 'String', String - return true if expr == '' - when 'Array', 'Hash', Array, Hash - return true if expr.empty? - else - return false - end - false - end - - def find_object(obj, hash) - current = hash[obj.to_s] - if current.nil? - Chef::Log.debug("Nagios debug: Creating entry for #{obj.class} with name: #{obj}") - hash[obj.to_s] = obj - obj - else - Chef::Log.debug("Nagios debug: Found entry for #{obj.class} with name: #{obj}") - current - end - end - - def get_groups(obj) - groups = obj['roles'].nil? ? [] : obj['roles'].dup - groups += [obj['os']] unless blank?(obj['os']) - groups + [obj.chef_environment] - end - - def get_hostname(obj) - return obj.name if @host_name_attribute == 'name' - return obj['nagios']['host_name'] unless blank?(obj['nagios']) || blank?(obj['nagios']['host_name']) - return obj[@host_name_attribute] unless blank?(obj[@host_name_attribute]) - return obj['hostname'] unless blank?(obj['hostname']) - return obj.name unless blank?(obj.name) - nil - end - - def push_node(obj) - groups = get_groups(obj) - hostname = get_hostname(obj) - return nil if hostname.nil? - - host = find(Nagios::Host.new(hostname)) - # TODO: merge the ip_to_monitor funtion into this logic here - host.address = obj['ipaddress'] - host.import(obj['nagios']) unless obj['nagios'].nil? - - groups.each do |r| - hg = find(Nagios::Hostgroup.new(r)) - hg.push(host) - host.push(hg) - end - end - - - def push_object(obj) - object = find(obj.class.new(obj.to_s)) - object.merge!(obj) - end -end diff --git a/cookbooks/nagios/libraries/resource.rb b/cookbooks/nagios/libraries/resource.rb deleted file mode 100644 index 695fc72a6..000000000 --- a/cookbooks/nagios/libraries/resource.rb +++ /dev/null @@ -1,59 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: resource -# -# Copyright 2015, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require_relative 'base' - -class Nagios - # - # This class holds all methods with regard to resource options, - # that are used within nagios configurations. - # - class Resource < Nagios::Base - attr_reader :key - attr_accessor :value - - def initialize(key, value = nil) - @key = key - @value = value - super() - end - - def definition - if blank?(value) - "# Skipping #{key} because the value is missing." - elsif key =~ /^USER([1-9]|[1-9][0-9]|[1-2][0-4][0-9]|25[0-6])$/ - "$#{@key}$=#{@value}" - else - "# Skipping #{key} because the it's not valid. Use USER[1-256] as your key." - end - end - - def self.create(name) - Nagios.instance.find(Nagios::Resource.new(name)) - end - - def import(hash) - update_options(hash) - end - - def to_s - key - end - end -end diff --git a/cookbooks/nagios/libraries/service.rb b/cookbooks/nagios/libraries/service.rb deleted file mode 100644 index 41aeada8e..000000000 --- a/cookbooks/nagios/libraries/service.rb +++ /dev/null @@ -1,455 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: service -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -require_relative 'base' - -class Nagios - # - # This class holds all methods with regard to servicedependency options, - # that are used within nagios configurations. - # - class Service < Nagios::Base - attr_reader :service_description, - :host_name, - :hostgroup_name, - :contacts, - :contact_groups, - :check_command, - :servicegroups, - :hostgroups, - :custom_options - - attr_accessor :display_name, - :is_volatile, - :initial_state, - :max_check_attempts, - :check_interval, - :retry_interval, - :active_checks_enabled, - :passive_checks_enabled, - :check_period, - :obsess_over_service, - :check_freshness, - :freshness_threshold, - :event_handler, - :event_handler_enabled, - :low_flap_threshold, - :high_flap_threshold, - :flap_detection_enabled, - :flap_detection_options, - :process_perf_data, - :retain_status_information, - :retain_nonstatus_information, - :notification_interval, - :first_notification_delay, - :notification_period, - :notification_options, - :notifications_enabled, - :parallelize_check, - :stalking_options, - :notes, - :notes_url, - :action_url, - :icon_image, - :icon_image_alt - - def initialize(service_description) - @service_description = service_description - srv = service_description.split('!') - @check_command = srv.shift - @arguments = srv - @servicegroups = {} - @contacts = {} - @contact_groups = {} - @hostgroups = {} - @hosts = {} - @custom_options = {} - super() - end - - - def check_command - if blank?(@arguments) - @check_command.to_s - else - @check_command.to_s + '!' + @arguments.join('!') - end - end - - def check_command=(cmd) - cmd = cmd.split('!') - cmd.shift - @arguments = cmd - end - - def check_period - get_timeperiod(@check_period) - end - - # contacts - # This is a list of the short names of the contacts that should be notified - # whenever there are problems (or recoveries) with this host. - # Multiple contacts should be separated by commas. - # Useful if you want notifications to go to just a few people and don't want - # to configure contact groups. - # You must specify at least one contact or contact group in each host definition. - def contacts_list - @contacts.values.map(&:to_s).sort.join(',') - end - - # contact_groups - # This is a list of the short names of the contact groups that should be notified - # whenever there are problems (or recoveries) with this host. - # Multiple contact groups should be separated by commas. - # You must specify at least one contact or contact group in each host definition. - def contact_groups_list - @contact_groups.values.map(&:to_s).sort.join(',') - end - - def definition - if blank?(hostgroup_name_list) && blank?(host_name_list) && name.nil? - "# Skipping #{service_description} because host_name and hostgroup_name are missing." - else - configured = configured_options - custom_options.each { |_, v| configured[v.to_s] = v.value } - get_definition(configured, 'service') - end - end - - # host_name - # This directive is used to return all host objects - def host_name - @hosts - end - - # host_name_list - # This directive is used to specify the short name(s) of the host(s) that the service - # "runs" on or is associated with. Multiple hosts should be separated by commas. - def host_name_list - @hosts.values.map(&:to_s).sort.join(',') - end - - # hostgroup_name - # This directive is used to return all hostgroup objects - def hostgroup_name - @hostgroups - end - - # hostgroup_name_list - # This directive is used to specify the short name(s) of the hostgroup(s) that the - # service "runs" on or is associated with. Multiple hostgroups should be separated by commas. - # The hostgroup_name may be used instead of, or in addition to, the host_name directive. - def hostgroup_name_list - @hostgroups.values.map(&:to_s).sort.join(',') - end - - def import(hash) - update_options(hash) - update_members(hash, 'contacts', Nagios::Contact) - update_members(hash, 'contact_groups', Nagios::Contactgroup) - update_members(hash, 'host_name', Nagios::Host) - update_members(hash, 'hostgroup_name', Nagios::Hostgroup) - update_members(hash, 'servicegroups', Nagios::Servicegroup, true) - update_members(hash, 'check_command', Nagios::Command) - end - - def notification_period - get_timeperiod(@notification_period) - end - - def push(obj) - case obj - when Nagios::Servicegroup - push_object(obj, @servicegroups) - when Nagios::Hostgroup - push_object(obj, @hostgroups) - when Nagios::Host - push_object(obj, @hosts) - when Nagios::Contact - push_object(obj, @contacts) - when Nagios::Contactgroup - push_object(obj, @contact_groups) - when Nagios::Command - @check_command = obj - when Nagios::Timeperiod - @check_period = obj - @notification_period = obj - when Nagios::CustomOption - push_object(obj, @custom_options) - end - end - - def pop(obj) - return if obj == self - case obj - when Nagios::Servicegroup - if @servicegroups.keys?(obj.to_s) - pop_object(obj, @servicegroups) - pop(self, obj) - end - when Nagios::Hostgroup - if @hostgroups.keys?(obj.to_s) - pop_object(obj, @hostgroups) - pop(self, obj) - end - when Nagios::Host - if @hosts.keys?(obj.to_s) - pop_object(obj, @hosts) - pop(self, obj) - end - when Nagios::Contact - if @contacts.keys?(obj.to_s) - pop_object(obj, @contacts) - pop(self, obj) - end - when Nagios::Contactgroup - if @contact_groups.keys?(obj.to_s) - pop_object(obj, @contact_groups) - pop(self, obj) - end - when Nagios::Command - @check_command = nil if @check_command == obj - when Nagios::Timeperiod - @check_period = nil if @check_command == obj - @notification_period = nil if @check_command == obj - when Nagios::CustomOption - if @custom_options.keys?(obj.to_s) - pop_object(obj, @custom_options) - pop(self, obj) - end - end - end - - - # servicegroups - # This directive is used to define the description of the service, which may contain spaces, - # dashes, and colons (semicolons, apostrophes, and quotation marks should be avoided). - # No two services associated with the same host can have the same description. - # Services are uniquely identified with their host_name and service_description directives. - def servicegroups_list - @servicegroups.values.map(&:to_s).sort.join(',') - end - - def self.create(name) - Nagios.instance.find(Nagios::Service.new(name)) - end - - def to_s - service_description - end - - # check the integer options - # default = nil - - def max_check_attempts=(int) - @max_check_attempts = check_integer(int) - end - - def check_interval=(int) - @check_interval = check_integer(int) - end - - def retry_interval=(int) - @retry_interval = check_integer(int) - end - - def freshness_threshold=(int) - @freshness_threshold = check_integer(int) - end - - def low_flap_threshold=(int) - @low_flap_threshold = check_integer(int) - end - - def high_flap_threshold=(int) - @high_flap_threshold = check_integer(int) - end - - def notification_interval=(int) - @notification_interval = check_integer(int) - end - - def first_notification_delay=(int) - @first_notification_delay = check_integer(int) - end - - # check the True/False options - # default = nil - - # rubocop:disable Style/PredicateName - def is_volatile=(arg) - @is_volatile = check_bool(arg) - end - # rubocop:enable Style/PredicateName - - def active_checks_enabled=(arg) - @active_checks_enabled = check_bool(arg) - end - - def passive_checks_enabled=(arg) - @passive_checks_enabled = check_bool(arg) - end - - def obsess_over_service=(arg) - @obsess_over_service = check_bool(arg) - end - - def check_freshness=(arg) - @check_freshness = check_bool(arg) - end - - def event_handler_enabled=(arg) - @event_handler_enabled = check_bool(arg) - end - - def flap_detection_enabled=(arg) - @flap_detection_enabled = check_bool(arg) - end - - def process_perf_data=(arg) - @process_perf_data = check_bool(arg) - end - - def retain_status_information=(arg) - @retain_status_information = check_bool(arg) - end - - def retain_nonstatus_information=(arg) - @retain_nonstatus_information = check_bool(arg) - end - - def notifications_enabled=(arg) - @notifications_enabled = check_bool(arg) - end - - def parallelize_check=(arg) - @parallelize_check = check_bool(arg) - end - - # check other options - - # flap_detection_options - # This directive is used to determine what service states the flap detection logic will use for this service. - # Valid options are a combination of one or more of the following: - # o = OK states, - # w = WARNING states, - # c = CRITICAL states, - # u = UNKNOWN states. - - def flap_detection_options=(arg) - @flap_detection_options = check_state_options(arg, %w(o w u c), 'flap_detection_options') - end - - # notification_options - # This directive is used to determine when notifications for the service should be sent out. - # Valid options are a combination of one or more of the following: - # w = send notifications on a WARNING state, - # u = send notifications on an UNKNOWN state, - # c = send notifications on a CRITICAL state, - # r = send notifications on recoveries (OK state), - # f = send notifications when the service starts and stops flapping, - # s = send notifications when scheduled downtime starts and ends. - # - # If you specify n (none) as an option, no service notifications will be sent out. - # If you do not specify any notification options, Nagios will assume that you want - # notifications to be sent out for all possible states. - # - # Example: If you specify w,r in this field, notifications will only be sent out when - # the service goes into a WARNING state and when it recovers from a WARNING state. - - def notification_options=(arg) - @notification_options = check_state_options(arg, %w(w u c r f s n), 'notification_options') - end - - # stalking_options - # This directive determines which service states "stalking" is enabled for. - # Valid options are a combination of one or more of the following: - # o = stalk on OK states, - # w = stalk on WARNING states, - # u = stalk on UNKNOWN states, - # c = stalk on CRITICAL states. - # - # More information on state stalking can be found here. - - def stalking_options=(arg) - @stalking_options = check_state_options(arg, %w(o w u c), 'stalking_options') - end - - private - - def config_options - { - 'name' => 'name', - 'use' => 'use', - 'service_description' => 'service_description', - 'host_name_list' => 'host_name', - 'hostgroup_name_list' => 'hostgroup_name', - 'servicegroups_list' => 'servicegroups', - 'display_name' => 'display_name', - 'is_volatile' => 'is_volatile', - 'check_command' => 'check_command', - 'initial_state' => 'initial_state', - 'max_check_attempts' => 'max_check_attempts', - 'check_interval' => 'check_interval', - 'retry_interval' => 'retry_interval', - 'active_checks_enabled' => 'active_checks_enabled', - 'passive_checks_enabled' => 'passive_checks_enabled', - 'check_period' => 'check_period', - 'obsess_over_service' => 'obsess_over_service', - 'check_freshness' => 'check_freshness', - 'freshness_threshold' => 'freshness_threshold', - 'event_handler' => 'event_handler', - 'event_handler_enabled' => 'event_handler_enabled', - 'low_flap_threshold' => 'low_flap_threshold', - 'high_flap_threshold' => 'high_flap_threshold', - 'flap_detection_enabled' => 'flap_detection_enabled', - 'flap_detection_options' => 'flap_detection_options', - 'process_perf_data' => 'process_perf_data', - 'retain_status_information' => 'retain_status_information', - 'retain_nonstatus_information' => 'retain_nonstatus_information', - 'notification_interval' => 'notification_interval', - 'first_notification_delay' => 'first_notification_delay', - 'notification_period' => 'notification_period', - 'notification_options' => 'notification_options', - 'notifications_enabled' => 'notifications_enabled', - 'parallelize_check' => 'parallelize_check', - 'contacts_list' => 'contacts', - 'contact_groups_list' => 'contact_groups', - 'stalking_options' => 'stalking_options', - 'notes' => 'notes', - 'notes_url' => 'notes_url', - 'action_url' => 'action_url', - 'icon_image' => 'icon_image', - 'icon_image_alt' => 'icon_image_alt', - 'register' => 'register', - } - end - - - def merge_members(obj) - obj.contacts.each { |m| push(m) } - obj.host_name.each { |m| push(m) } - obj.servicegroups.each { |m| push(m) } - obj.hostgroup_name.each { |m| push(m) } - obj.contact_groups.each { |m| push(m) } - obj.custom_options.each { |_, m| push(m) } - end - end -end diff --git a/cookbooks/nagios/libraries/servicedependency.rb b/cookbooks/nagios/libraries/servicedependency.rb deleted file mode 100644 index 91f12b155..000000000 --- a/cookbooks/nagios/libraries/servicedependency.rb +++ /dev/null @@ -1,215 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: servicedependency -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -require_relative 'base' - -class Nagios - # - # This class holds all methods with regard to servicedependency options, - # that are used within nagios configurations. - # - class Servicedependency < Nagios::Base - attr_reader :service_description, - :dependency_period, - :dependent_host_name, - :dependent_hostgroup_name, - :dependent_servicegroup_name, - :host_name, - :hostgroup_name, - :servicegroup_name - - attr_accessor :dependent_service_description, - :inherits_parent, - :execution_failure_criteria, - :notification_failure_criteria - - def initialize(name) - @service_description = name - @host_name = {} - @hostgroup_name = {} - @servicegroup_name = {} - @dependent_host_name = {} - @dependent_hostgroup_name = {} - @dependent_servicegroup_name = {} - super() - end - - def definition - get_definition(configured_options, 'servicedependency') - end - - def dependent_host_name_list - @dependent_host_name.values.map(&:to_s).sort.join(',') - end - - def dependent_hostgroup_name_list - @dependent_hostgroup_name.values.map(&:to_s).sort.join(',') - end - - def dependent_servicegroup_name_list - @dependent_servicegroup_name.values.map(&:to_s).sort.join(',') - end - - def host_name_list - @host_name.values.map(&:to_s).sort.join(',') - end - - def hostgroup_name_list - @hostgroup_name.values.map(&:to_s).sort.join(',') - end - - def servicegroup_name_list - @servicegroup_name.values.map(&:to_s).sort.join(',') - end - - def import(hash) - update_options(hash) - update_members(hash, 'host_name', Nagios::Host) - update_members(hash, 'hostgroup_name', Nagios::Hostgroup) - update_members(hash, 'servicegroup_name', Nagios::Servicegroup) - update_dependency_members(hash, 'dependent_host_name', Nagios::Host) - update_dependency_members(hash, 'dependent_hostgroup_name', Nagios::Hostgroup) - update_dependency_members(hash, 'dependent_servicegroup_name', Nagios::Servicegroup) - end - - def push(obj) - case obj - when Nagios::Host - push_object(obj, @host_name) - when Nagios::Hostgroup - push_object(obj, @hostgroup_name) - when Nagios::Servicegroup - push_object(obj, @servicegroup_name) - when Nagios::Timeperiod - @dependency_period = obj - end - end - - def pop(obj) - return if obj == self - case obj - when Nagios::Host - if @host_name.keys?(obj.to_s) - pop_object(obj, @host_name) - pop(self, obj) - end - when Nagios::Hostgroup - if @host_name.keys?(obj.to_s) - pop_object(obj, @hostgroup_name) - pop(self, obj) - end - when Nagios::Servicegroup - if @host_name.keys?(obj.to_s) - pop_object(obj, @servicegroup_name) - pop(self, obj) - end - when Nagios::Timeperiod - @dependency_period = nil if @dependency_period == obj - end - end - - def pop_dependency(obj) - return if obj == self - case obj - when Nagios::Host - if @dependent_host_name.keys?(obj.to_s) - pop_object(obj, @dependent_host_name) - obj.pop(self) - end - when Nagios::Hostgroup - if @dependent_hostgroup_name.keys?(obj.to_s) - pop_object(obj, @dependent_hostgroup_name) - obj.pop(self) - end - when Nagios::Servicegroup - if @dependent_servicegroup_name.keys?(obj.to_s) - pop_object(obj, @dependent_servicegroup_name) - obj.pop(self) - end - end - end - - - def push_dependency(obj) - case obj - when Nagios::Host - push_object(obj, @dependent_host_name) - when Nagios::Hostgroup - push_object(obj, @dependent_hostgroup_name) - when Nagios::Servicegroup - push_object(obj, @dependent_servicegroup_name) - end - end - - def self.create(name) - Nagios.instance.find(Nagios::Servicedependency.new(name)) - end - - def to_s - service_description - end - - # check the True/False options - # default = nil - - def inherits_parent=(arg) - @inherits_parent = check_bool(arg) - end - - # check other options - - def execution_failure_criteria=(arg) - @execution_failure_criteria = check_state_options(arg, %w(o w u c p n), 'execution_failure_criteria') - end - - def notification_failure_criteria=(arg) - @notification_failure_criteria = check_state_options(arg, %w(o w u c p n), 'notification_failure_criteria') - end - - private - - def config_options - { - 'dependency_period' => 'dependency_period', - 'dependent_host_name_list' => 'dependent_host_name', - 'dependent_hostgroup_name_list' => 'dependent_hostgroup_name', - 'dependent_servicegroup_name_list' => 'dependent_servicegroup_name', - 'service_description' => 'service_description', - 'servicegroup_name_list' => 'servicegroup_name', - 'dependent_service_description' => 'dependent_service_description', - 'host_name_list' => 'host_name', - 'hostgroup_name_list' => 'hostgroup_name', - 'inherits_parent' => 'inherits_parent', - 'execution_failure_criteria' => 'execution_failure_criteria', - 'notification_failure_criteria' => 'notification_failure_criteria', - } - end - - - def merge_members(obj) - obj.host_name.each { |m| push(m) } - obj.hostgroup_name.each { |m| push(m) } - obj.servicegroup_name.each { |m| push(m) } - obj.dependent_host_name.each { |m| push_dependency(m) } - obj.dependent_hostgroup_name.each { |m| push_dependency(m) } - obj.dependent_servicegroup_name.each { |m| dependent_servicegroup_name(m) } - end - end -end diff --git a/cookbooks/nagios/libraries/serviceescalation.rb b/cookbooks/nagios/libraries/serviceescalation.rb deleted file mode 100644 index 23148d08c..000000000 --- a/cookbooks/nagios/libraries/serviceescalation.rb +++ /dev/null @@ -1,195 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: serviceescalation -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require_relative 'base' - -class Nagios - # - # This class holds all methods with regard to serviceescalation options, - # that are used within nagios configurations. - # - class Serviceescalation < Nagios::Base - attr_reader :service_description, - :host_name, - :hostgroup_name, - :servicegroup_name, - :contacts, - :contact_groups - - attr_accessor :first_notification, - :last_notification, - :notification_interval, - :escalation_options, - :escalation_period - - def initialize(name) - @service_description = name - @contacts = {} - @contact_groups = {} - @host_name = {} - @hostgroup_name = {} - @servicegroup_name = {} - super() - end - - def definition - configured = configured_options - unless blank?(servicegroup_name) - configured.delete('service_description') - configured.delete('host_name') - end - get_definition(configured, 'serviceescalation') - end - - def contacts_list - @contacts.values.map(&:to_s).sort.join(',') - end - - def contact_groups_list - @contact_groups.values.map(&:to_s).sort.join(',') - end - - def host_name_list - @host_name.values.map(&:to_s).sort.join(',') - end - - def hostgroup_name_list - @hostgroup_name.values.map(&:to_s).sort.join(',') - end - - def servicegroup_name_list - @servicegroup_name.values.map(&:to_s).sort.join(',') - end - - def import(hash) - update_options(hash) - update_members(hash, 'contacts', Nagios::Contact) - update_members(hash, 'contact_groups', Nagios::Contactgroup) - update_members(hash, 'host_name', Nagios::Host) - update_members(hash, 'hostgroup_name', Nagios::Hostgroup) - update_members(hash, 'servicegroup_name', Nagios::Servicegroup) - end - - def push(obj) - case obj - when Nagios::Host - push_object(obj, @host_name) - when Nagios::Hostgroup - push_object(obj, @hostgroup_name) - when Nagios::Servicegroup - push_object(obj, @servicegroup_name) - when Nagios::Contact - push_object(obj, @contacts) - when Nagios::Contactgroup - push_object(obj, @contact_groups) - when Nagios::Timeperiod - @escalation_period = obj - end - end - - def pop(obj) - return if obj == self - case obj - when Nagios::Host - if @host_name.keys?(obj.to_s) - pop_object(obj, @host_name) - pop(self, obj) - end - when Nagios::Hostgroup - if @hostgroup_name.keys?(obj.to_s) - pop_object(obj, @hostgroup_name) - pop(self, obj) - end - when Nagios::Servicegroup - if @servicegroup_name.keys?(obj.to_s) - pop_object(obj, @servicegroup_name) - pop(self, obj) - end - when Nagios::Contact - if @contacts.keys?(obj.to_s) - pop_object(obj, @contacts) - pop(self, obj) - end - when Nagios::Contactgroup - if @contact_groups.keys?(obj.to_s) - pop_object(obj, @contact_groups) - pop(self, obj) - end - when Nagios::Timeperiod - @escalation_period = nil if @escalation_period == obj - end - end - - - def to_s - service_description - end - - # check the integer options - # default = nil - - def first_notification=(int) - @first_notification = check_integer(int) - end - - def last_notification=(int) - @last_notification = check_integer(int) - end - - def notification_interval=(int) - @notification_interval = check_integer(int) - end - - # check other options - - def escalation_options=(arg) - @escalation_options = check_state_options(arg, %w(w u c r), 'escalation_options') - end - - private - - def config_options - { - 'name' => 'name', - 'use' => 'use', - 'service_description' => 'service_description', - 'contacts_list' => 'contacts', - 'contact_groups_list' => 'contact_groups', - 'escalation_period' => 'escalation_period', - 'host_name_list' => 'host_name', - 'hostgroup_name_list' => 'hostgroup_name', - 'servicegroup_name_list' => 'servicegroup_name', - 'escalation_options' => 'escalation_options', - 'first_notification' => 'first_notification', - 'last_notification' => 'last_notification', - 'notification_interval' => 'notification_interval', - 'register' => 'register', - } - end - - - def merge_members(obj) - obj.contacts.each { |m| push(m) } - obj.host_name.each { |m| push(m) } - obj.contact_groups.each { |m| push(m) } - obj.hostgroup_name.each { |m| push(m) } - obj.servicegroup_name.each { |m| push(m) } - end - end -end diff --git a/cookbooks/nagios/libraries/servicegroup.rb b/cookbooks/nagios/libraries/servicegroup.rb deleted file mode 100644 index ba7913440..000000000 --- a/cookbooks/nagios/libraries/servicegroup.rb +++ /dev/null @@ -1,144 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: servicegroup -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require_relative 'base' - -class Nagios - # - # This class holds all methods with regard to servicegroup options, - # that are used within nagios configurations. - # - class Servicegroup < Nagios::Base - attr_reader :servicegroup_name, - :members, - :servicegroup_members - - attr_accessor :alias, - :notes, - :notes_url, - :action_url - - def initialize(servicegroup_name) - @servicegroup_name = servicegroup_name - @members = {} - @servicegroup_members = {} - super() - end - - def definition - get_definition(configured_options, 'servicegroup') - end - - def import(hash) - update_options(hash) - update_members(hash, 'members', Nagios::Service, true) - update_members(hash, 'servicegroup_members', Nagios::Servicegroup, true) - end - - def members_list - result = lookup_hostgroup_members - result.join(',') - end - - def push(obj) - case obj - when Nagios::Service - push_object(obj, @members) - when Nagios::Servicegroup - push_object(obj, @servicegroup_members) - end - end - - def pop(obj) - return if obj == self - case obj - when Nagios::Service - if @members.keys?(obj.to_s) - pop_object(obj, @members) - pop(self, obj) - end - when Nagios::Servicegroup - if @servicegroup_members.keys?(obj.to_s) - pop_object(obj, @servicegroup_members) - pop(self, obj) - end - end - end - - - def self.create(name) - Nagios.instance.find(Nagios::Servicegroup.new(name)) - end - - def servicegroup_members_list - @servicegroup_members.values.map(&:to_s).sort.join(',') - end - - def to_s - servicegroup_name - end - - private - - def config_options - { - 'servicegroup_name' => 'servicegroup_name', - 'members_list' => 'members', - 'servicegroup_members_list' => 'servicegroup_members', - 'alias' => 'alias', - 'notes' => 'notes', - 'notes_url' => 'notes_url', - 'action_url' => 'action_url', - } - end - - def convert_hostgroup_hash(hash) - result = [] - hash.sort.to_h.each do |group_name, group_members| - group_members.sort.each do |member| - result << member - result << group_name - end - end - result - end - - def lookup_hostgroup_members - hostgroup_hash = {} - @members.each do |service_name, service_obj| - hostgroup_array = [] - service_obj.hostgroups.each do |hostgroup_name, hostgroup_obj| - if service_obj.not_modifiers['hostgroup_name'][hostgroup_name] != '!' - hostgroup_array += hostgroup_obj.members.keys - else - hostgroup_array -= hostgroup_obj.members.keys - end - end - hostgroup_hash[service_name] = hostgroup_array - end - convert_hostgroup_hash(hostgroup_hash) - end - - - def merge_members(obj) - obj.members.each { |m| push(m) } - obj.servicegroup_members.each { |m| push(m) } - end - end -end diff --git a/cookbooks/nagios/libraries/timeperiod.rb b/cookbooks/nagios/libraries/timeperiod.rb deleted file mode 100644 index b1b3146cd..000000000 --- a/cookbooks/nagios/libraries/timeperiod.rb +++ /dev/null @@ -1,160 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Library:: timeperiod -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -require_relative 'base' - -class Nagios - # - # This class holds all methods with regard to timeperiodentries, - # that are used within the timeperiod nagios configurations. - # - class Timeperiodentry - attr_reader :moment, - :period - - def initialize(moment, period) - @moment = moment - @period = check_period(period) - end - - def to_s - moment - end - - private - - def check_period(period) - return period if period =~ /^(([01]?[0-9]|2[0-3])\:[0-5][0-9]-([01]?[0-9]|2[0-4])\:[0-5][0-9],?)*$/ - nil - end - end - - # - # This class holds all methods with regard to timeperiod options, - # that are used within nagios configurations. - # - class Timeperiod < Nagios::Base - attr_reader :timeperiod_name - - attr_accessor :alias, - :periods, - :exclude - - def initialize(timeperiod_name) - @timeperiod_name = timeperiod_name - @periods = {} - @exclude = {} - super() - end - - def self.create(name) - Nagios.instance.find(Nagios::Timeperiod.new(name)) - end - - def definition - configured = configured_options - periods.values.each { |v| configured[v.moment] = v.period } - get_definition(configured, 'timeperiod') - end - - # exclude - # This directive is used to specify the short names of other timeperiod definitions - # whose time ranges should be excluded from this timeperiod. - # Multiple timeperiod names should be separated with a comma. - - def exclude - @exclude.values.map(&:to_s).sort.join(',') - end - - def import(hash) - update_options(hash) - if hash['times'].respond_to?('each_pair') - hash['times'].each { |k, v| push(Nagios::Timeperiodentry.new(k, v)) } - end - update_members(hash, 'exclude', Nagios::Timeperiod) - end - - def push(obj) - case obj - when Nagios::Timeperiod - push_object(obj, @exclude) - when Nagios::Timeperiodentry - push_object(obj, @periods) unless obj.period.nil? - end - end - - def pop(obj) - return if obj == self - case obj - when Nagios::Timeperiod - if @exclude.keys?(obj.to_s) - pop_object(obj, @exclude) - pop(self, obj) - end - when Nagios::Timeperiodentry - if @periods.keys?(obj.to_s) - pop_object(obj, @periods) - pop(self, obj) - end - end - end - - - def to_s - timeperiod_name - end - - # [weekday] - # The weekday directives ("sunday" through "saturday")are comma-delimited - # lists of time ranges that are "valid" times for a particular day of the week. - # Notice that there are seven different days for which you can define time - # ranges (Sunday through Saturday). Each time range is in the form of - # HH:MM-HH:MM, where hours are specified on a 24 hour clock. - # For example, 00:15-24:00 means 12:15am in the morning for this day until - # 12:00am midnight (a 23 hour, 45 minute total time range). - # If you wish to exclude an entire day from the timeperiod, simply do not include - # it in the timeperiod definition. - - # [exception] - # You can specify several different types of exceptions to the standard rotating - # weekday schedule. Exceptions can take a number of different forms including single - # days of a specific or generic month, single weekdays in a month, or single calendar - # dates. You can also specify a range of days/dates and even specify skip intervals - # to obtain functionality described by "every 3 days between these dates". - # Rather than list all the possible formats for exception strings, I'll let you look - # at the example timeperiod definitions above to see what's possible. - # Weekdays and different types of exceptions all have different levels of precedence, - # so its important to understand how they can affect each other. - - private - - def config_options - { - 'timeperiod_name' => 'timeperiod_name', - 'alias' => 'alias', - 'exclude' => 'exclude', - } - end - - def merge_members(obj) - obj.periods.each { |m| push(m) } - obj.exclude.each { |m| push(m) } - end - end -end diff --git a/cookbooks/nagios/libraries/users_helper.rb b/cookbooks/nagios/libraries/users_helper.rb deleted file mode 100644 index 260ddfa48..000000000 --- a/cookbooks/nagios/libraries/users_helper.rb +++ /dev/null @@ -1,54 +0,0 @@ -require 'chef/log' -require 'chef/search/query' - -# Simplify access to list of all valid Nagios users -class NagiosUsers - attr_accessor :users - - def initialize(node) - @node = node - @users = [] - - user_databag = node['nagios']['users_databag'].to_sym - group = node['nagios']['users_databag_group'] - - if node['nagios']['server']['use_encrypted_data_bags'] - load_encrypted_databag(user_databag) - else - search_databag(user_databag, group) - end - end - - def return_user_contacts - contacts = [] - # add base contacts from nagios_users data bag - @users.each do |s| - contacts << s['id'] - end - contacts - end - - private - - def fail_search(user_databag) - Chef::Log.fatal("\"#{user_databag}\" databag could not be found.") - raise "\"#{user_databag}\" databag could not be found." - end - - def load_encrypted_databag(user_databag) - Chef::DataBag.load(user_databag).each do |u, _| - d = Chef::EncryptedDataBagItem.load(user_databag, u) # ~FC086 - @users << d unless d['nagios'].nil? || d['nagios']['email'].nil? - end - rescue Net::HTTPServerException - fail_search(user_databag) - end - - def search_databag(user_databag, group) - Chef::Search::Query.new.search(user_databag, "groups:#{group} NOT action:remove") do |d| - @users << d unless d['nagios'].nil? || d['nagios']['email'].nil? - end - rescue Net::HTTPServerException - fail_search(user_databag) - end -end diff --git a/cookbooks/nagios/metadata.rb b/cookbooks/nagios/metadata.rb deleted file mode 100644 index 15d9c7b82..000000000 --- a/cookbooks/nagios/metadata.rb +++ /dev/null @@ -1,25 +0,0 @@ -name 'nagios' -maintainer 'Mu' -maintainer_email 'mu-developers@googlegroups.com' -license 'BSD-3-Clause' -description 'Installs and configures Nagios server' -long_description IO.read(File.join(File.dirname(__FILE__), 'README.md')) -version '7.2.7' -source_url 'https://github.com/cloudamatic/mu' -issues_url 'https://github.com/cloudamatic/mu/issues' -chef_version '>= 14.0' if respond_to?(:chef_version) - -recipe 'default', 'Installs Nagios server.' -recipe 'nagios::pagerduty', 'Integrates contacts w/ PagerDuty API' - -depends 'apache2', '< 4.0' -depends 'php', '< 6.0' -depends 'zap', '>= 0.6.0' - -%w(chef_nginx nginx_simplecgi yum-epel nrpe ).each do |cb| - depends cb -end - -%w( debian ubuntu redhat centos fedora scientific amazon oracle).each do |os| - supports os -end diff --git a/cookbooks/nagios/recipes/_load_databag_config.rb b/cookbooks/nagios/recipes/_load_databag_config.rb deleted file mode 100644 index e4b52db06..000000000 --- a/cookbooks/nagios/recipes/_load_databag_config.rb +++ /dev/null @@ -1,153 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Recipe:: _load_databag_config -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Loading all databag information -nagios_bags = NagiosDataBags.new - -hostgroups = nagios_bags.get(node['nagios']['hostgroups_databag']) -hostgroups.each do |group| - next if group['search_query'].nil? - if node['nagios']['multi_environment_monitoring'] - query_environments = node['nagios']['monitored_environments'].map do |environment| - "chef_environment:#{environment}" - end.join(' OR ') - result = search(:node, "(#{group['search_query']}) AND (#{query_environments})") - else - result = search(:node, "#{group['search_query']} AND chef_environment:#{node.chef_environment}") - end - - result.each do |n| - n.automatic_attrs['roles'] = [group['hostgroup_name']] - Nagios.instance.push(n) - end -end - -services = nagios_bags.get(node['nagios']['services_databag']) -services.each do |item| - next unless item['activate_check_in_environment'].nil? || item['activate_check_in_environment'].include?(node.chef_environment) - name = item['service_description'] || item['id'] - check_command = name.downcase.start_with?('check_') ? name.downcase : 'check_' + name.downcase - command_name = item['check_command'].nil? ? check_command : item['check_command'] - service_name = name.downcase.start_with?('check_') ? name.gsub('check_', '') : name.downcase - item['check_command'] = command_name - - nagios_command command_name do - options item - end - - nagios_service service_name do - options item - end -end - -contactgroups = nagios_bags.get(node['nagios']['contactgroups_databag']) -contactgroups.each do |item| - name = item['contactgroup_name'] || item['id'] - nagios_contactgroup name do - options item - end -end - -eventhandlers = nagios_bags.get(node['nagios']['eventhandlers_databag']) -eventhandlers.each do |item| - name = item['command_name'] || item['id'] - nagios_command name do - options item - end -end - -contacts = nagios_bags.get(node['nagios']['contacts_databag']) -contacts.each do |item| - name = item['contact_name'] || item['id'] - nagios_contact name do - options item - end -end - -hostescalations = nagios_bags.get(node['nagios']['hostescalations_databag']) -hostescalations.each do |item| - name = item['host_description'] || item['id'] - nagios_hostescalation name do - options item - end -end - -hosttemplates = nagios_bags.get(node['nagios']['hosttemplates_databag']) -hosttemplates.each do |item| - name = item['host_name'] || item['id'] - item['name'] = name if item['name'].nil? - nagios_host name do - options item - end -end - -servicedependencies = nagios_bags.get(node['nagios']['servicedependencies_databag']) -servicedependencies.each do |item| - name = item['service_description'] || item['id'] - nagios_servicedependency name do - options item - end -end - -serviceescalations = nagios_bags.get(node['nagios']['serviceescalations_databag']) -serviceescalations.each do |item| - name = item['service_description'] || item['id'] - nagios_serviceescalation name do - options item - end -end - -servicegroups = nagios_bags.get(node['nagios']['servicegroups_databag']) -servicegroups.each do |item| - name = item['servicegroup_name'] || item['id'] - nagios_servicegroup name do - options item - end -end - -templates = nagios_bags.get(node['nagios']['templates_databag']) -templates.each do |item| - name = item['name'] || item['id'] - item['name'] = name - nagios_service name do - options item - end -end - -timeperiods = nagios_bags.get(node['nagios']['timeperiods_databag']) -timeperiods.each do |item| - name = item['timeperiod_name'] || item['id'] - nagios_timeperiod name do - options item - end -end - -unmanaged_hosts = nagios_bags.get(node['nagios']['unmanagedhosts_databag']) -unmanaged_hosts.each do |item| - if node['nagios']['multi_environment_monitoring'].nil? - next if item['environment'].nil? || item['environment'] != node.chef_environment - else - envs = node['nagios']['monitored_environments'] - next if item['environment'].nil? || !envs.include?(item['environment']) - end - name = item['host_name'] || item['id'] - nagios_host name do - options item - end -end diff --git a/cookbooks/nagios/recipes/_load_default_config.rb b/cookbooks/nagios/recipes/_load_default_config.rb deleted file mode 100644 index 42e594f6f..000000000 --- a/cookbooks/nagios/recipes/_load_default_config.rb +++ /dev/null @@ -1,241 +0,0 @@ -# -# Author:: Sander Botman -# Cookbook Name:: nagios -# Recipe:: _load_default_config -# -# Copyright 2014, Sander Botman -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# Find nodes to monitor. -# Search in all environments if multi_environment_monitoring is enabled. -Chef::Log.info('Beginning search for nodes. This may take some time depending on your node count') - -multi_env = node['nagios']['monitored_environments'] -multi_env_search = multi_env.empty? ? '' : ' AND (chef_environment:' + multi_env.join(' OR chef_environment:') + ')' - -nodes = if node['nagios']['multi_environment_monitoring'] - search(:node, "name:*#{multi_env_search}") - else - search(:node, "name:* AND chef_environment:#{node.chef_environment}") - end - -if nodes.empty? - Chef::Log.info('No nodes returned from search, using this node so hosts.cfg has data') - nodes << node -end - -# Pushing current node to prevent empty hosts.cfg -Nagios.instance.push(node) - -# Pushing all nodes into the Nagios.instance model -exclude_tag = nagios_array(node['nagios']['exclude_tag_host']) -nodes.each do |n| - if n.respond_to?('tags') - Nagios.instance.push(n) unless nagios_array(n.tags).any? { |tag| exclude_tag.include?(tag) } - else - Nagios.instance.push(n) - end -end - -# 24x7 timeperiod -nagios_timeperiod '24x7' do - options 'alias' => '24 Hours A Day, 7 Days A Week', - 'times' => { 'sunday' => '00:00-24:00', - 'monday' => '00:00-24:00', - 'tuesday' => '00:00-24:00', - 'wednesday' => '00:00-24:00', - 'thursday' => '00:00-24:00', - 'friday' => '00:00-24:00', - 'saturday' => '00:00-24:00', - } -end - -# Host checks -nagios_command 'check_host_alive' do - options 'command_line' => '$USER1$/check_ping -H $HOSTADDRESS$ -w 2000,80% -c 3000,100% -p 1' -end - -# Service checks -nagios_command 'check_nagios' do - options 'command_line' => '$USER1$/check_nrpe -H $HOSTADDRESS$ -c check_nagios -t 20' -end - -# nrpe remote host checks -nagios_command 'check_nrpe_alive' do - options 'command_line' => '$USER1$/check_nrpe -H $HOSTADDRESS$ -t 20' -end - -nagios_command 'check_nrpe' do - options 'command_line' => '$USER1$/check_nrpe -H $HOSTADDRESS$ -c $ARG1$ -t 20' -end - -# host_notify_by_email command -nagios_command 'host_notify_by_email' do - options 'command_line' => '/usr/bin/printf "%b" "$LONGDATETIME$\n\n$HOSTALIAS$ $NOTIFICATIONTYPE$ $HOSTSTATE$\n\n$HOSTOUTPUT$\n\nLogin: ssh://$HOSTNAME$" | ' + node['nagios']['server']['mail_command'] + ' -s "$NOTIFICATIONTYPE$ - $HOSTALIAS$ $HOSTSTATE$!" $CONTACTEMAIL$' -end - -# service_notify_by_email command -nagios_command 'service_notify_by_email' do - options 'command_line' => '/usr/bin/printf "%b" "$LONGDATETIME$ - $SERVICEDESC$ $SERVICESTATE$\n\n$HOSTALIAS$ $NOTIFICATIONTYPE$\n\n$SERVICEOUTPUT$\n\nLogin: ssh://$HOSTNAME$" | ' + node['nagios']['server']['mail_command'] + ' -s "** $NOTIFICATIONTYPE$ - $HOSTALIAS$ - $SERVICEDESC$ - $SERVICESTATE$" $CONTACTEMAIL$' -end - -# host_notify_by_sms_email command -nagios_command 'host_notify_by_sms_email' do - options 'command_line' => '/usr/bin/printf "%b" "$HOSTALIAS$ $NOTIFICATIONTYPE$ $HOSTSTATE$\n\n$HOSTOUTPUT$" | ' + node['nagios']['server']['mail_command'] + ' -s "$HOSTALIAS$ $HOSTSTATE$!" $CONTACTPAGER$' -end - -# service_notify_by_sms_email command -nagios_command 'service_notify_by_sms_email' do - options 'command_line' => '/usr/bin/printf "%b" "$SERVICEDESC$ $NOTIFICATIONTYPE$ $SERVICESTATE$\n\n$SERVICEOUTPUT$" | ' + node['nagios']['server']['mail_command'] + ' -s "$HOSTALIAS$ $SERVICEDESC$ $SERVICESTATE$!" $CONTACTPAGER$' -end - -# root contact -nagios_contact 'root' do - options 'alias' => 'Root', - 'service_notification_period' => '24x7', - 'host_notification_period' => '24x7', - 'service_notification_options' => 'w,u,c,r', - 'host_notification_options' => 'd,r', - 'service_notification_commands' => 'service_notify_by_email', - 'host_notification_commands' => 'host_notify_by_email', - 'email' => 'root@localhost' -end - -# admin contact -nagios_contact 'admin' do - options 'alias' => 'Admin', - 'service_notification_period' => '24x7', - 'host_notification_period' => '24x7', - 'service_notification_options' => 'w,u,c,r', - 'host_notification_options' => 'd,r', - 'service_notification_commands' => 'service_notify_by_email', - 'host_notification_commands' => 'host_notify_by_email' -end - -nagios_contact 'default-contact' do - options 'name' => 'default-contact', - 'service_notification_period' => '24x7', - 'host_notification_period' => '24x7', - 'service_notification_options' => 'w,u,c,r,f', - 'host_notification_options' => 'd,u,r,f,s', - 'service_notification_commands' => 'service_notify_by_email', - 'host_notification_commands' => 'host_notify_by_email' -end - -nagios_host 'default-host' do - options 'name' => 'default-host', - 'notifications_enabled' => 1, - 'event_handler_enabled' => 1, - 'flap_detection_enabled' => nagios_boolean(nagios_attr(:default_host)[:flap_detection]), - 'process_perf_data' => nagios_boolean(nagios_attr(:default_host)[:process_perf_data]), - 'retain_status_information' => 1, - 'retain_nonstatus_information' => 1, - 'notification_period' => '24x7', - 'register' => 0, - 'action_url' => nagios_attr(:default_host)[:action_url] -end - -nagios_host 'server' do - options 'name' => 'server', - 'use' => 'default-host', - 'check_period' => nagios_attr(:default_host)[:check_period], - 'check_interval' => nagios_interval(nagios_attr(:default_host)[:check_interval]), - 'retry_interval' => nagios_interval(nagios_attr(:default_host)[:retry_interval]), - 'max_check_attempts' => nagios_attr(:default_host)[:max_check_attempts], - 'check_command' => nagios_attr(:default_host)[:check_command], - 'notification_interval' => nagios_interval(nagios_attr(:default_host)[:notification_interval]), - 'notification_options' => nagios_attr(:default_host)[:notification_options], - 'contact_groups' => nagios_attr(:default_contact_groups), - 'register' => 0 -end - -# Defaut host template -Nagios.instance.default_host = node['nagios']['host_template'] - -# Users -# use the users_helper.rb library to build arrays of users and contacts -nagios_users = NagiosUsers.new(node) -nagios_users.users.each do |item| - o = Nagios::Contact.create(item['id']) - o.import(item.to_hash) - o.import(item['nagios'].to_hash) unless item['nagios'].nil? - o.use = 'default-contact' -end - -nagios_contactgroup 'admins' do - options 'alias' => 'Nagios Administrators', - 'members' => nagios_users.return_user_contacts -end - -nagios_contactgroup 'admins-sms' do - options 'alias' => 'Sysadmin SMS', - 'members' => nagios_users.return_user_contacts -end - -# Services -nagios_service 'default-service' do - options 'name' => 'default-service', - 'active_checks_enabled' => 1, - 'passive_checks_enabled' => 1, - 'parallelize_check' => 1, - 'obsess_over_service' => 1, - 'check_freshness' => 0, - 'notifications_enabled' => 1, - 'event_handler_enabled' => 1, - 'flap_detection_enabled' => nagios_boolean(nagios_attr(:default_service)[:flap_detection]), - 'process_perf_data' => nagios_boolean(nagios_attr(:default_service)[:process_perf_data]), - 'retain_status_information' => 1, - 'retain_nonstatus_information' => 1, - 'is_volatile' => 0, - 'check_period' => '24x7', - 'max_check_attempts' => nagios_attr(:default_service)[:max_check_attempts], - 'check_interval' => nagios_interval(nagios_attr(:default_service)[:check_interval]), - 'retry_interval' => nagios_interval(nagios_attr(:default_service)[:retry_interval]), - 'contact_groups' => nagios_attr(:default_contact_groups), - 'notification_options' => 'w,u,c,r', - 'notification_interval' => nagios_interval(nagios_attr(:default_service)[:notification_interval]), - 'notification_period' => '24x7', - 'register' => 0, - 'action_url' => nagios_attr(:default_service)[:action_url] -end -# Default service template -Nagios.instance.default_service = 'default-service' - -# Define the log monitoring template (monitoring logs is very different) -nagios_service 'default-logfile' do - options 'name' => 'default-logfile', - 'use' => 'default-service', - 'check_period' => '24x7', - 'max_check_attempts' => 1, - 'check_interval' => nagios_interval(nagios_attr(:default_service)[:check_interval]), - 'retry_interval' => nagios_interval(nagios_attr(:default_service)[:retry_interval]), - 'contact_groups' => nagios_attr(:default_contact_groups), - 'notification_options' => 'w,u,c,r', - 'notification_period' => '24x7', - 'register' => 0, - 'is_volatile' => 1 -end - -nagios_service 'service-template' do - options 'name' => 'service-template', - 'max_check_attempts' => nagios_attr(:default_service)[:max_check_attempts], - 'check_interval' => nagios_interval(nagios_attr(:default_service)[:check_interval]), - 'retry_interval' => nagios_interval(nagios_attr(:default_service)[:retry_interval]), - 'notification_interval' => nagios_interval(nagios_attr(:default_service)[:notification_interval]), - 'register' => 0 -end - -nagios_resource 'USER1' do - options 'value' => node['nagios']['plugin_dir'] -end diff --git a/cookbooks/nagios/recipes/apache.rb b/cookbooks/nagios/recipes/apache.rb deleted file mode 100644 index b5f25efbf..000000000 --- a/cookbooks/nagios/recipes/apache.rb +++ /dev/null @@ -1,48 +0,0 @@ -# -# Author:: Tim Smith -# Cookbook Name:: nagios -# Recipe:: apache -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -include_recipe 'apache2' -include_recipe 'apache2::mod_rewrite' -include_recipe 'apache2::mod_php5' -include_recipe 'apache2::mod_ssl' if node['nagios']['enable_ssl'] - -apache_site '000-default' do - enable false -end - -apache_module 'cgi' - -template "#{node['apache']['dir']}/sites-available/#{node['nagios']['server']['vname']}.conf" do - source 'apache2.conf.erb' - mode '0644' - variables( - nagios_url: node['nagios']['url'], - https: node['nagios']['enable_ssl'], - ssl_cert_file: node['nagios']['ssl_cert_file'], - ssl_cert_key: node['nagios']['ssl_cert_key'] - ) - if File.symlink?("#{node['apache']['dir']}/sites-enabled/#{node['nagios']['server']['vname']}.conf") - notifies :reload, 'service[apache2]' - end -end - -file "#{node['apache']['dir']}/conf.d/#{node['nagios']['server']['vname']}.conf" do - action :delete -end - -apache_site node['nagios']['server']['vname'] diff --git a/cookbooks/nagios/recipes/default.rb b/cookbooks/nagios/recipes/default.rb deleted file mode 100644 index 5a5fe95ae..000000000 --- a/cookbooks/nagios/recipes/default.rb +++ /dev/null @@ -1,204 +0,0 @@ -# -# Author:: Joshua Sierles -# Author:: Joshua Timberman -# Author:: Nathan Haneysmith -# Author:: Seth Chisamore -# Author:: Tim Smith -# Cookbook Name:: nagios -# Recipe:: default -# -# Copyright 2009, 37signals -# Copyright 2009-2016, Chef Software, Inc. -# Copyright 2013-2014, Limelight Networks, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# workaround to allow for a nagios server install from source using the override attribute on debian/ubuntu (COOK-2350) -nagios_service_name = if platform_family?('debian') && node['nagios']['server']['install_method'] == 'source' - node['nagios']['server']['name'] - else - node['nagios']['server']['service_name'] - end - -# install nagios service either from source of package -include_recipe "nagios::server_#{node['nagios']['server']['install_method']}" - -# configure either Apache2 or NGINX -case node['nagios']['server']['web_server'] -when 'nginx' - Chef::Log.info 'Setting up Nagios server via NGINX' - include_recipe 'nagios::nginx' - web_user = node['nginx']['user'] - web_group = node['nginx']['group'] || web_user -when 'apache' - Chef::Log.info 'Setting up Nagios server via Apache2' - include_recipe 'nagios::apache' - web_user = node['apache']['user'] - web_group = node['apache']['group'] || web_user -else - Chef::Log.fatal('Unknown web server option provided for Nagios server: ' \ - "#{node['nagios']['server']['web_server']} provided. Allowed: 'nginx' or 'apache'") - raise 'Unknown web server option provided for Nagios server' -end - -# use the users_helper.rb library to build arrays of users and contacts -nagios_users = NagiosUsers.new(node) - -Chef::Log.fatal("Could not find users in the \"#{node['nagios']['users_databag']}\" databag with the \"#{node['nagios']['users_databag_group']}\"" \ - ' group. Users must be defined to allow for logins to the UI. Make sure the databag exists and, if you have set the ' \ - '"users_databag_group", that users in that group exist.') if nagios_users.users.empty? - -# configure the appropriate authentication method for the web server -case node['nagios']['server_auth_method'] -when 'openid' - if node['nagios']['server']['web_server'] == 'apache' - include_recipe 'apache2::mod_auth_openid' - else - Chef::Log.fatal('OpenID authentication for Nagios is not supported on NGINX') - Chef::Log.fatal("Set node['nagios']['server_auth_method'] attribute in your Nagios role") - raise 'OpenID authentication not supported on NGINX' - end -when 'cas' - if node['nagios']['server']['web_server'] == 'apache' - include_recipe 'apache2::mod_auth_cas' - else - Chef::Log.fatal('CAS authentication for Nagios is not supported on NGINX') - Chef::Log.fatal("Set node['nagios']['server_auth_method'] attribute in your Nagios role") - raise 'CAS authentivation not supported on NGINX' - end -when 'ldap' - if node['nagios']['server']['web_server'] == 'apache' - include_recipe 'apache2::mod_authnz_ldap' - else - Chef::Log.fatal('LDAP authentication for Nagios is not supported on NGINX') - Chef::Log.fatal("Set node['nagios']['server_auth_method'] attribute in your Nagios role") - raise 'LDAP authentication not supported on NGINX' - end -else - # setup htpasswd auth - directory node['nagios']['conf_dir'] - - template "#{node['nagios']['conf_dir']}/htpasswd.users" do - source 'htpasswd.users.erb' - owner node['nagios']['user'] - group web_group - mode '0640' - variables(nagios_users: nagios_users.users) - end -end - -# Setting all general options -unless node['nagios'].nil? - unless node['nagios']['server'].nil? - Nagios.instance.normalize_hostname = node['nagios']['server']['normalize_hostname'] - end -end - -Nagios.instance.host_name_attribute = node['nagios']['host_name_attribute'] - -# loading default configuration data -if node['nagios']['server']['load_default_config'] - include_recipe 'nagios::_load_default_config' -end - -# loading all databag configurations -if node['nagios']['server']['load_databag_config'] - include_recipe 'nagios::_load_databag_config' -end - -directory "#{node['nagios']['conf_dir']}/dist" do - owner node['nagios']['user'] - group node['nagios']['group'] - mode '0755' -end - -directory node['nagios']['state_dir'] do - owner node['nagios']['user'] - group node['nagios']['group'] - mode '0751' -end - -directory "#{node['nagios']['state_dir']}/rw" do - owner node['nagios']['user'] - group web_group - mode '2710' -end - -execute 'archive-default-nagios-object-definitions' do - command "mv #{node['nagios']['config_dir']}/*_#{node['nagios']['server']['name']}*.cfg #{node['nagios']['conf_dir']}/dist" - not_if { Dir.glob("#{node['nagios']['config_dir']}/*_#{node['nagios']['server']['name']}*.cfg").empty? } -end - -directory "#{node['nagios']['conf_dir']}/certificates" do - owner web_user - group web_group - mode '0700' -end - -ssl_code = "umask 077 -openssl genrsa 2048 > nagios-server.key -openssl req -subj #{node['nagios']['ssl_req']} -new -x509 -nodes -sha1 -days 3650 -key nagios-server.key > nagios-server.crt -cat nagios-server.key nagios-server.crt > nagios-server.pem" - -bash 'Create SSL Certificates' do - cwd "#{node['nagios']['conf_dir']}/certificates" - code ssl_code - not_if { ::File.exist?(node['nagios']['ssl_cert_file']) } -end - -nagios_conf node['nagios']['server']['name'] do - config_subdir false - source 'nagios.cfg.erb' - variables(nagios_config: node['nagios']['conf']) -end - -nagios_conf 'cgi' do - config_subdir false - variables(nagios_service_name: nagios_service_name) -end - -# resource.cfg differs on RPM and tarball based systems -if node['platform_family'] == 'rhel' || node['platform_family'] == 'fedora' - template "#{node['nagios']['resource_dir']}/resource.cfg" do - source 'resource.cfg.erb' - owner node['nagios']['user'] - group node['nagios']['group'] - mode '0600' - end - - directory node['nagios']['resource_dir'] do - owner 'root' - group node['nagios']['group'] - mode '0755' - end -end - -nagios_conf 'timeperiods' -nagios_conf 'contacts' -nagios_conf 'commands' -nagios_conf 'hosts' -nagios_conf 'hostgroups' -nagios_conf 'templates' -nagios_conf 'services' -nagios_conf 'servicegroups' -nagios_conf 'servicedependencies' - -zap_directory node['nagios']['config_dir'] do - pattern '*.cfg' -end - -service 'nagios' do - service_name nagios_service_name - supports status: true, restart: true, reload: true - action [:enable, :start] -end diff --git a/cookbooks/nagios/recipes/nginx.rb b/cookbooks/nagios/recipes/nginx.rb deleted file mode 100644 index 816c8103d..000000000 --- a/cookbooks/nagios/recipes/nginx.rb +++ /dev/null @@ -1,82 +0,0 @@ -# -# Author:: Tim Smith -# Cookbook Name:: nagios -# Recipe:: nginx -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -if node['nagios']['server']['stop_apache'] - service 'apache2' do - action :stop - end -end - -# This doesn't use value_for_platform_family so that it can specify version ranges - COOK-2891 -if platform_family?('rhel') || platform_family?('fedora') - node.normal['nagios']['server']['nginx_dispatch'] = 'both' - if node['platform_version'].to_f < 6 - node.normal['nginx']['install_method'] = 'source' - end -end - -include_recipe 'chef_nginx' - -%w(default 000-default).each do |disable_site| - nginx_site disable_site do - enable false - notifies :reload, 'service[nginx]' - end -end - -case dispatch_type = node['nagios']['server']['nginx_dispatch'] -when 'cgi' - node.normal['nginx_simplecgi']['cgi'] = true - include_recipe 'nginx_simplecgi::setup' -when 'php' - node.normal['nginx_simplecgi']['php'] = true - include_recipe 'nginx_simplecgi::setup' -when 'both' - node.normal['nginx_simplecgi']['php'] = true - node.normal['nginx_simplecgi']['cgi'] = true - include_recipe 'nginx_simplecgi::setup' -else - Chef::Log.warn 'NAGIOS: NGINX setup does not have a dispatcher provided' -end - -template File.join(node['nginx']['dir'], 'sites-available', 'nagios3.conf') do - source 'nginx.conf.erb' - mode '0644' - variables( - public_domain: node['public_domain'] || node['domain'], - listen_port: node['nagios']['http_port'], - https: node['nagios']['enable_ssl'], - ssl_cert_file: node['nagios']['ssl_cert_file'], - ssl_cert_key: node['nagios']['ssl_cert_key'], - docroot: node['nagios']['docroot'], - log_dir: node['nagios']['log_dir'], - fqdn: node['fqdn'], - nagios_url: node['nagios']['url'], - chef_env: node.chef_environment == '_default' ? 'default' : node.chef_environment, - htpasswd_file: File.join(node['nagios']['conf_dir'], 'htpasswd.users'), - cgi: %w(cgi both).include?(dispatch_type), - php: %w(php both).include?(dispatch_type) - ) - if File.symlink?(File.join(node['nginx']['dir'], 'sites-enabled', 'nagios3.conf')) - notifies :reload, 'service[nginx]', :immediately - end -end - -nginx_site 'nagios3.conf' do - notifies :reload, 'service[nginx]' -end diff --git a/cookbooks/nagios/recipes/pagerduty.rb b/cookbooks/nagios/recipes/pagerduty.rb deleted file mode 100644 index f32279031..000000000 --- a/cookbooks/nagios/recipes/pagerduty.rb +++ /dev/null @@ -1,143 +0,0 @@ -# -# Author:: Jake Vanderdray -# Author:: Tim Smith -# Cookbook Name:: nagios -# Recipe:: pagerduty -# -# Copyright 2011, CustomInk LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# TODO: remove when backward compatibility is dropped. -def using_old_pagerduty_key_attribute? - node['nagios']['pagerduty_key'] && - node['nagios']['pagerduty_key'] != node['nagios']['pagerduty']['key'] -end - -if using_old_pagerduty_key_attribute? - Chef::Log.warn('The nagios.pagerduty_key attribute is deprecated. It is replaced by the nagios.pagerduty.key attribute.') - Chef::Log.warn('Assigning nagios.pagerduty.key from nagios.pagerduty_key now.') - node.normal['nagios']['pagerduty']['key'] = node['nagios']['pagerduty_key'] -end - -package 'perl-CGI' do - case node['platform_family'] - when 'rhel', 'fedora' - package_name 'perl-CGI' - when 'debian' - package_name 'libcgi-pm-perl' - when 'arch' - package_name 'perl-cgi' - end - action :install -end - -package 'perl-JSON' do - case node['platform_family'] - when 'rhel', 'fedora' - package_name 'perl-JSON' - when 'debian' - package_name 'libjson-perl' - when 'arch' - package_name 'perl-json' - end - action :install -end - -package 'libwww-perl' do - case node['platform_family'] - when 'rhel', 'fedora' - package_name 'perl-libwww-perl' - when 'debian' - package_name 'libwww-perl' - when 'arch' - package_name 'libwww-perl' - end - action :install -end - -package 'libcrypt-ssleay-perl' do - case node['platform_family'] - when 'rhel', 'fedora' - package_name 'perl-Crypt-SSLeay' - when 'debian' - package_name 'libcrypt-ssleay-perl' - when 'arch' - package_name 'libcrypt-ssleay-perl' - end - action :install -end - -remote_file "#{node['nagios']['plugin_dir']}/notify_pagerduty.pl" do - owner 'root' - group 'root' - mode '0755' - source node['nagios']['pagerduty']['script_url'] - action :create_if_missing -end - -template "#{node['nagios']['cgi-bin']}/pagerduty.cgi" do - source 'pagerduty.cgi.erb' - owner node['nagios']['user'] - group node['nagios']['group'] - mode '0755' - variables( - command_file: node['nagios']['conf']['command_file'] - ) -end - -nagios_bags = NagiosDataBags.new -pagerduty_contacts = nagios_bags.get('nagios_pagerduty') - -nagios_command 'notify-service-by-pagerduty' do - options 'command_line' => ::File.join(node['nagios']['plugin_dir'], 'notify_pagerduty.pl') + ' enqueue -f pd_nagios_object=service -f pd_description="$HOSTNAME$ : $SERVICEDESC$"' -end - -nagios_command 'notify-host-by-pagerduty' do - options 'command_line' => ::File.join(node['nagios']['plugin_dir'], 'notify_pagerduty.pl') + ' enqueue -f pd_nagios_object=host -f pd_description="$HOSTNAME$ : $SERVICEDESC$"' -end - -unless node['nagios']['pagerduty']['key'].nil? || node['nagios']['pagerduty']['key'].empty? - nagios_contact 'pagerduty' do - options 'alias' => 'PagerDuty Pseudo-Contact', - 'service_notification_period' => '24x7', - 'host_notification_period' => '24x7', - 'service_notification_options' => node['nagios']['pagerduty']['service_notification_options'], - 'host_notification_options' => node['nagios']['pagerduty']['host_notification_options'], - 'service_notification_commands' => 'notify-service-by-pagerduty', - 'host_notification_commands' => 'notify-host-by-pagerduty', - 'pager' => node['nagios']['pagerduty']['key'] - end -end - -pagerduty_contacts.each do |contact| - name = contact['contact'] || contact['id'] - - nagios_contact name do - options 'alias' => "PagerDuty Pseudo-Contact #{name}", - 'service_notification_period' => contact['service_notification_period'] || '24x7', - 'host_notification_period' => contact['host_notification_period'] || '24x7', - 'service_notification_options' => contact['service_notification_options'] || 'w,u,c,r', - 'host_notification_options' => contact['host_notification_options'] || 'd,r', - 'service_notification_commands' => 'notify-service-by-pagerduty', - 'host_notification_commands' => 'notify-host-by-pagerduty', - 'pager' => contact['key'] || contact['pagerduty_key'], - 'contactgroups' => contact['contactgroups'] - end -end - -cron 'Flush Pagerduty' do - user node['nagios']['user'] - mailto 'root@localhost' - command "#{::File.join(node['nagios']['plugin_dir'], 'notify_pagerduty.pl')} flush" -end diff --git a/cookbooks/nagios/recipes/server_package.rb b/cookbooks/nagios/recipes/server_package.rb deleted file mode 100644 index 5ce948538..000000000 --- a/cookbooks/nagios/recipes/server_package.rb +++ /dev/null @@ -1,40 +0,0 @@ -# -# Author:: Seth Chisamore -# Author:: Tim Smith -# Cookbook Name:: nagios -# Recipe:: server_package -# -# Copyright 2011-2016, Chef Software, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -case node['platform_family'] -when 'rhel' - include_recipe 'yum-epel' if node['nagios']['server']['install_yum-epel'] -when 'debian' - # Nagios package requires to enter the admin password - # We generate it randomly as it's overwritten later in the config templates - random_initial_password = rand(36**16).to_s(36) - - %w(adminpassword adminpassword-repeat).each do |setting| - execute "debconf-set-selections::#{node['nagios']['server']['vname']}-cgi::#{node['nagios']['server']['vname']}/#{setting}" do - command "echo #{node['nagios']['server']['vname']}-cgi #{node['nagios']['server']['vname']}/#{setting} password #{random_initial_password} | debconf-set-selections" - not_if "dpkg -l #{node['nagios']['server']['vname']}" - end - end -end - -node['nagios']['server']['packages'].each do |pkg| - package pkg -end diff --git a/cookbooks/nagios/recipes/server_source.rb b/cookbooks/nagios/recipes/server_source.rb deleted file mode 100644 index 90918cb8c..000000000 --- a/cookbooks/nagios/recipes/server_source.rb +++ /dev/null @@ -1,164 +0,0 @@ -# -# Author:: Seth Chisamore -# Author:: Tim Smith -# Cookbook Name:: nagios -# Recipe:: server_source -# -# Copyright 2011-2016, Chef Software, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -# Package pre-reqs -include_recipe 'php::default' -include_recipe 'php::module_gd' - -build_essential 'name' do - compile_time True -end - -# the source install of nagios from this recipe does not include embedded perl support -# so unless the user explicitly set the p1_file attribute, we want to clear it -# Note: the cookbook now defaults to Nagios 4.X which doesn't support embedded perl anyways -node.default['nagios']['conf']['p1_file'] = nil - -pkgs = value_for_platform_family( - %w( rhel fedora ) => %w( openssl-devel gd-devel tar ), - 'debian' => %w( libssl-dev libgd2-xpm-dev bsd-mailx tar ), - 'default' => %w( libssl-dev libgd2-xpm-dev bsd-mailx tar ) -) - -pkgs.each do |pkg| - package pkg do - action :install - end -end - -user node['nagios']['user'] do - action :create -end - -web_srv = node['nagios']['server']['web_server'] - -group node['nagios']['group'] do - members [ - node['nagios']['user'], - web_srv == 'nginx' ? node['nginx']['user'] : node['apache']['user'], - ] - action :create -end - -remote_file "#{Chef::Config[:file_cache_path]}/nagios_core.tar.gz" do - source node['nagios']['server']['url'] - checksum node['nagios']['server']['checksum'] -end - -node['nagios']['server']['patches'].each do |patch| - remote_file "#{Chef::Config[:file_cache_path]}/#{patch}" do - source "#{node['nagios']['server']['patch_url']}/#{patch}" - end -end - -execute 'extract-nagios' do - cwd Chef::Config[:file_cache_path] - command 'tar zxvf nagios_core.tar.gz' - not_if { ::File.exist?("#{Chef::Config[:file_cache_path]}/#{node['nagios']['server']['src_dir']}") } -end - -node['nagios']['server']['patches'].each do |patch| - bash "patch-#{patch}" do - cwd Chef::Config[:file_cache_path] - code <<-EOF - cd #{node['nagios']['server']['src_dir']} - patch -p1 --forward --silent --dry-run < '#{Chef::Config[:file_cache_path]}/#{patch}' >/dev/null - if [ $? -eq 0 ]; then - patch -p1 --forward < '#{Chef::Config[:file_cache_path]}/#{patch}' - else - exit 0 - fi - EOF - action :nothing - subscribes :run, 'execute[extract-nagios]', :immediately - end -end - -bash 'compile-nagios' do - cwd Chef::Config[:file_cache_path] - code <<-EOH - cd #{node['nagios']['server']['src_dir']} - ./configure --prefix=/usr \ - --mandir=/usr/share/man \ - --bindir=/usr/sbin \ - --sbindir=#{node['nagios']['cgi-bin']} \ - --datadir=#{node['nagios']['docroot']} \ - --sysconfdir=#{node['nagios']['conf_dir']} \ - --infodir=/usr/share/info \ - --libexecdir=#{node['nagios']['plugin_dir']} \ - --localstatedir=#{node['nagios']['state_dir']} \ - --enable-event-broker \ - --with-nagios-user=#{node['nagios']['user']} \ - --with-nagios-group=#{node['nagios']['group']} \ - --with-command-user=#{node['nagios']['user']} \ - --with-command-group=#{node['nagios']['group']} \ - --with-init-dir=/etc/init.d \ - --with-lockfile=#{node['nagios']['run_dir']}/#{node['nagios']['server']['vname']}.pid \ - --with-mail=/usr/bin/mail \ - --with-perlcache \ - --with-htmurl=/ \ - --with-cgiurl=#{node['nagios']['cgi-path']} - make all - make install - make install-init - make install-config - make install-commandmode - #{node['nagios']['source']['add_build_commands'].join("\n")} - EOH - action :nothing - subscribes :run, 'execute[extract-nagios]', :immediately -end - -directory node['nagios']['config_dir'] do - owner 'root' - group 'root' - mode '0755' - recursive true -end - -directory node['nagios']['conf']['check_result_path'] do - owner node['nagios']['user'] - group node['nagios']['group'] - mode '0755' - recursive true -end - -%w( cache_dir log_dir run_dir ).each do |dir| - directory "Nagios cookbook #{node['nagios'][dir]}" do - path node['nagios'][dir] - recursive true - owner node['nagios']['user'] - group node['nagios']['group'] - mode '0755' - end -end - -directory ::File.join(node['nagios']['log_dir'], 'archives') do - owner node['nagios']['user'] - group node['nagios']['group'] - mode '0755' -end - -directory "/usr/lib/#{node['nagios']['server']['vname']}" do - owner node['nagios']['user'] - group node['nagios']['group'] - mode '0755' -end diff --git a/cookbooks/nagios/templates/default/apache2.conf.erb b/cookbooks/nagios/templates/default/apache2.conf.erb deleted file mode 100644 index 72fbda6e5..000000000 --- a/cookbooks/nagios/templates/default/apache2.conf.erb +++ /dev/null @@ -1,96 +0,0 @@ -# Autogenerated by Chef. - -<% unless node['nagios']['ldap_verify_cert'].nil? %>LDAPVerifyServerCert <%= node['nagios']['ldap_verify_cert'] %><% end %> -<% unless node['nagios']['ldap_trusted_mode'].nil? -%>LDAPTrustedMode <%= node['nagios']['ldap_trusted_mode'] %> <% end -%> -<% unless node['nagios']['ldap_trusted_global_cert'].nil? -%>LDAPTrustedGlobalCert <%= node['nagios']['ldap_trusted_global_cert'] %> <% end -%> - -> - ServerAdmin <%= node['nagios']['sysadmin_email'] %> -<% if @nagios_url %> - ServerName <%= @nagios_url %> -<% else %> - ServerName <%= node['fqdn'] %> -<% if node['nagios']['server']['server_alias'] %> - ServerAlias <%= node['nagios']['server']['server_alias'] %> -<% end %> -<% end %> - DocumentRoot <%= node['nagios']['docroot'] %> - CustomLog <%= node['apache']['log_dir'] %>/nagios_access.log combined - ErrorLog <%= node['apache']['log_dir'] %>/nagios_error.log - -<% if node['platform_family'] == 'debian' && node['nagios']['server']['install_method'] == 'package'-%> - Alias /stylesheets /etc/<%= node['nagios']['server']['vname'] %>/stylesheets - Alias /nagios3/stylesheets /etc/<%= node['nagios']['server']['vname'] %>/stylesheets -<% end -%> - ScriptAlias <%= node['nagios']['cgi-path'] %> <%= node['nagios']['cgi-bin'] %> - ScriptAlias /cgi-bin/statusjson.cgi <%= node['nagios']['cgi-bin'] %>/statusjson.cgi - Alias /<%= node['nagios']['server']['vname'] %> <%= node['nagios']['docroot'] %> - - "> - Options ExecCGI - <% if node['nagios']['default_user_name'] -%> - require all granted - <% end -%> - - -<% if @https -%> - SSLEngine On - SSLProtocol all -SSLv3 -SSLv2 - SSLCertificateFile <%= @ssl_cert_file %> -<% if node['nagios']['ssl_cert_chain_file'] %> - SSLCertificateChainFile <%= node['nagios']['ssl_cert_chain_file'] %> -<% end -%> - SSLCertificateKeyFile <%= @ssl_cert_key %> - -<% end -%> -<% case node['nagios']['server_auth_method'] -%> -<% when "openid" -%> - - AuthName "Nagios Server" - AuthType OpenID - require user <%= node['apache']['allowed_openids'].join(' ') %> - AuthOpenIDDBLocation <%= node['apache']['mod_auth_openid']['dblocation'] %> - -<% when "cas" -%> - CASLoginURL <%= node['nagios']['cas_login_url'] %> - CASValidateURL <%= node['nagios']['cas_validate_url'] %> - CASValidateServer <%= node['nagios']['cas_validate_server'] %> - <% if node['nagios']['cas_root_proxy_url'] -%> - CASRootProxiedAs <%= node['nagios']['cas_root_proxy_url'] %> - <% end -%> - - - AuthType CAS - require <%= node['nagios']['server_auth_require'] %> - -<% when "ldap" -%> - - AuthName "Nagios Server" - AuthType Basic - AuthBasicProvider ldap - <% unless node['nagios']['ldap_group_attribute_is_dn'].nil? %>AuthLDAPGroupAttributeIsDN <%= node['nagios']['ldap_group_attribute_is_dn'] %><% end %> - <% unless node['nagios']['ldap_group_attribute'].nil? -%>AuthLDAPGroupAttribute "<%= node['nagios']['ldap_group_attribute'] %>" <% end -%> - <% unless node['nagios']['ldap_bind_dn'].nil? -%>AuthLDAPBindDN "<%= node['nagios']['ldap_bind_dn'] %>" <% end -%> - <% unless node['nagios']['ldap_bind_password'].nil? -%>AuthLDAPBindPassword "<%= node['nagios']['ldap_bind_password'] %>"<% end -%> - AuthLDAPURL "<%= node['nagios']['ldap_url'] %>" - <% if node['apache']['version'] < "2.4" and !node['nagios']['ldap_authoritative'].nil? %>AuthzLDAPAuthoritative <%= node['nagios']['ldap_authoritative'] %><% end %> - require <%= node['nagios']['server_auth_require'] %> - -<% else -%> - - AuthName "Nagios Server" - AuthType Basic - AuthUserFile "<%= node['nagios']['conf_dir'] %>/htpasswd.users" - require <%= node['nagios']['server_auth_require'] %> - <% unless node['nagios']['allowed_ips'].empty? -%> - Order Deny,Allow - Deny from All - Allow from <%=node['nagios']['allowed_ips'].join(' ') %> - Satisfy Any - <% end -%> - -<% end -%> - - SetEnv TZ "<%= node['nagios']['conf']['use_timezone'] %>" - - diff --git a/cookbooks/nagios/templates/default/cgi.cfg.erb b/cookbooks/nagios/templates/default/cgi.cfg.erb deleted file mode 100644 index 78884662c..000000000 --- a/cookbooks/nagios/templates/default/cgi.cfg.erb +++ /dev/null @@ -1,266 +0,0 @@ -# Autogenerated by Chef. -# -# MAIN CONFIGURATION FILE -# This tells the CGIs where to find your main configuration file. -# The CGIs will read the main and host config files for any other -# data they might need. - -main_config_file=<%= node['nagios']['conf_dir'] %>/<%= node['nagios']['server']['name'] %>.cfg - -# PHYSICAL HTML PATH -# This is the path where the HTML files for Nagios reside. This -# value is used to locate the logo images needed by the statusmap -# and statuswrl CGIs. - -physical_html_path=<%= node['nagios']['docroot'] %> - -# URL HTML PATH -# This is the path portion of the URL that corresponds to the -# physical location of the Nagios HTML files (as defined above). -# This value is used by the CGIs to locate the online documentation -# and graphics. If you access the Nagios pages with an URL like -# http://www.myhost.com/nagios, this value should be '/nagios' -# (without the quotes). - -url_html_path=/<%= node['nagios']['server']['vname'] %> - -# CONTEXT-SENSITIVE HELP -# This option determines whether or not a context-sensitive -# help icon will be displayed for most of the CGIs. -# Values: 0 = disables context-sensitive help -# 1 = enables context-sensitive help - -show_context_help=<%= node['nagios']['cgi']['show_context_help'] %> - -# NAGIOS PROCESS CHECK COMMAND -# This is the full path and filename of the program used to check -# the status of the Nagios process. It is used only by the CGIs -# and is completely optional. However, if you don't use it, you'll -# see warning messages in the CGIs about the Nagios process -# not running and you won't be able to execute any commands from -# the web interface. The program should follow the same rules -# as plugins; the return codes are the same as for the plugins, -# it should have timeout protection, it should output something -# to STDIO, etc. -# -# Note: The command line for the check_nagios plugin below may -# have to be tweaked a bit, as different versions of the plugin -# use different command line arguments/syntaxes. - -<%= node['nagios']['server']['name'] %>_check_command=<%= node['nagios']['plugin_dir'] %>/check_nagios <%= node['nagios']['cache_dir'] %>/status.dat 5 '/usr/sbin/<%= @nagios_service_name %>' - -# AUTHENTICATION USAGE -# This option controls whether or not the CGIs will use any -# authentication when displaying host and service information, as -# well as committing commands to Nagios for processing. -# -# Read the HTML documentation to learn how the authorization works! -# -# NOTE: It is a really *bad* idea to disable authorization, unless -# you plan on removing the command CGI (cmd.cgi)! Failure to do -# so will leave you wide open to kiddies messing with Nagios and -# possibly hitting you with a denial of service attack by filling up -# your drive by continuously writing to your command file! -# -# Setting this value to 0 will cause the CGIs to *not* use -# authentication (bad idea), while any other value will make them -# use the authentication functions (the default). - -use_authentication=1 - -# DEFAULT USER -# Setting this variable will define a default user name that can -# access pages without authentication. This allows people within a -# secure domain (i.e., behind a firewall) to see the current status -# without authenticating. You may want to use this to avoid basic -# authentication if you are not using a secure server since basic -# authentication transmits passwords in the clear. -# -# Important: Do not define a default username unless you are -# running a secure web server and are sure that everyone who has -# access to the CGIs has been authenticated in some manner! If you -# define this variable, anyone who has not authenticated to the web -# server will inherit all rights you assign to this user! - -<% if node['nagios']['default_user_name'] -%> -default_user_name=<%= @node['nagios']['default_user_name'] %> -<% else -%> -#default_user_name=guest -<% end -%> - -# SYSTEM/PROCESS INFORMATION ACCESS -# This option is a comma-delimited list of all usernames that -# have access to viewing the Nagios process information as -# provided by the Extended Information CGI (extinfo.cgi). By -# default, *no one* has access to this unless you choose to -# not use authorization. You may use an asterisk (*) to -# authorize any user who has authenticated to the web server. - -authorized_for_system_information=<%= node['nagios']['cgi']['authorized_for_system_information'] %> - -# CONFIGURATION INFORMATION ACCESS -# This option is a comma-delimited list of all usernames that -# can view ALL configuration information (hosts, commands, etc). -# By default, users can only view configuration information -# for the hosts and services they are contacts for. You may use -# an asterisk (*) to authorize any user who has authenticated -# to the web server. - -authorized_for_configuration_information=<%= node['nagios']['cgi']['authorized_for_configuration_information'] %> - -# SYSTEM/PROCESS COMMAND ACCESS -# This option is a comma-delimited list of all usernames that -# can issue shutdown and restart commands to Nagios via the -# command CGI (cmd.cgi). Users in this list can also change -# the program mode to active or standby. By default, *no one* -# has access to this unless you choose to not use authorization. -# You may use an asterisk (*) to authorize any user who has -# authenticated to the web server. - -authorized_for_system_commands=<%= node['nagios']['cgi']['authorized_for_system_commands'] %> - -# GLOBAL HOST/SERVICE VIEW ACCESS -# These two options are comma-delimited lists of all usernames that -# can view information for all hosts and services that are being -# monitored. By default, users can only view information -# for hosts or services that they are contacts for (unless you -# you choose to not use authorization). You may use an asterisk (*) -# to authorize any user who has authenticated to the web server. - - -authorized_for_all_services=<%= node['nagios']['cgi']['authorized_for_all_services'] %> -authorized_for_all_hosts=<%= node['nagios']['cgi']['authorized_for_all_hosts'] %> - -# GLOBAL HOST/SERVICE COMMAND ACCESS -# These two options are comma-delimited lists of all usernames that -# can issue host or service related commands via the command -# CGI (cmd.cgi) for all hosts and services that are being monitored. -# By default, users can only issue commands for hosts or services -# that they are contacts for (unless you you choose to not use -# authorization). You may use an asterisk (*) to authorize any -# user who has authenticated to the web server. - -authorized_for_all_service_commands=<%= node['nagios']['cgi']['authorized_for_all_service_commands'] %> -authorized_for_all_host_commands=<%= node['nagios']['cgi']['authorized_for_all_host_commands'] %> - -# STATUSMAP BACKGROUND IMAGE -# This option allows you to specify an image to be used as a -# background in the statusmap CGI. It is assumed that the image -# resides in the HTML images path (i.e. /usr/local/nagios/share/images). -# This path is automatically determined by appending "/images" -# to the path specified by the 'physical_html_path' directive. -# Note: The image file may be in GIF, PNG, JPEG, or GD2 format. -# However, I recommend that you convert your image to GD2 format -# (uncompressed), as this will cause less CPU load when the CGI -# generates the image. - -#statusmap_background_image=smbackground.gd2 - -# DEFAULT STATUSMAP LAYOUT METHOD -# This option allows you to specify the default layout method -# the statusmap CGI should use for drawing hosts. If you do -# not use this option, the default is to use user-defined -# coordinates. Valid options are as follows: -# 0 = User-defined coordinates -# 1 = Depth layers -# 2 = Collapsed tree -# 3 = Balanced tree -# 4 = Circular -# 5 = Circular (Marked Up) - -default_statusmap_layout=<%= node['nagios']['cgi']['default_statusmap_layout'] %> - -# DEFAULT STATUSWRL LAYOUT METHOD -# This option allows you to specify the default layout method -# the statuswrl (VRML) CGI should use for drawing hosts. If you -# do not use this option, the default is to use user-defined -# coordinates. Valid options are as follows: -# 0 = User-defined coordinates -# 2 = Collapsed tree -# 3 = Balanced tree -# 4 = Circular - -default_statuswrl_layout=<%= node['nagios']['cgi']['default_statuswrl_layout'] %> - -# STATUSWRL INCLUDE -# This option allows you to include your own objects in the -# generated VRML world. It is assumed that the file -# resides in the HTML path (i.e. /usr/local/nagios/share). - -#statuswrl_include=myworld.wrl - -# PING SYNTAX -# This option determines what syntax should be used when -# attempting to ping a host from the WAP interface (using -# the statuswml CGI. You must include the full path to -# the ping binary, along with all required options. The -# $HOSTADDRESS$ macro is substituted with the address of -# the host before the command is executed. -# Please note that the syntax for the ping binary is -# notorious for being different on virtually ever *NIX -# OS and distribution, so you may have to tweak this to -# work on your system. - -ping_syntax=/bin/ping -n -U -c 5 $HOSTADDRESS$ - -# REFRESH RATE -# This option allows you to specify the refresh rate in seconds -# of various CGIs (status, statusmap, extinfo, and outages). - -refresh_rate=90 - -# DEFAULT PAGE LIMIT -# This option allows you to specify the default number of results -# displayed on the status.cgi. This number can be adjusted from -# within the UI after the initial page load. Setting this to 0 -# will show all results. - -result_limit=<%= node['nagios']['cgi']['result_limit'] %> - -# ESCAPE HTML TAGS -# This option determines whether HTML tags in host and service -# status output is escaped in the web interface. If enabled, -# your plugin output will not be able to contain clickable links. - -escape_html_tags=<%= node['nagios']['cgi']['escape_html_tags'] %> - -# SOUND OPTIONS -# These options allow you to specify an optional audio file -# that should be played in your browser window when there are -# problems on the network. The audio files are used only in -# the status CGI. Only the sound for the most critical problem -# will be played. Order of importance (higher to lower) is as -# follows: unreachable hosts, down hosts, critical services, -# warning services, and unknown services. If there are no -# visible problems, the sound file optionally specified by -# 'normal_sound' variable will be played. -# -# -# = -# -# Note: All audio files must be placed in the /media subdirectory -# under the HTML path (i.e. /usr/local/nagios/share/media/). - -#host_unreachable_sound=hostdown.wav -#host_down_sound=hostdown.wav -#service_critical_sound=critical.wav -#service_warning_sound=warning.wav -#service_unknown_sound=warning.wav -#normal_sound=noproblem.wav - -# URL TARGET FRAMES -# These options determine the target frames in which notes and -# action URLs will open. - -action_url_target=<%= node['nagios']['cgi']['action_url_target'] %> -notes_url_target=<%= node['nagios']['cgi']['notes_url_target'] %> - - -# LOCK AUTHOR NAMES OPTION -# This option determines whether users can change the author name -# when submitting comments, scheduling downtime. If disabled, the -# author names will be locked into their contact name, as defined in Nagios. -# Values: 0 = allow editing author names -# 1 = lock author names (disallow editing) - -lock_author_names=<%= node['nagios']['cgi']['lock_author_names'] %> diff --git a/cookbooks/nagios/templates/default/commands.cfg.erb b/cookbooks/nagios/templates/default/commands.cfg.erb deleted file mode 100644 index 740704962..000000000 --- a/cookbooks/nagios/templates/default/commands.cfg.erb +++ /dev/null @@ -1,13 +0,0 @@ -# $Id: Generated by chef for node: <%= node['hostname'] %> -# ---------------------------------------------------------------- -# NOTE: This file is controlled by chef templates! -# Do not edit or change this file but change the following: -# template file : commands.cfg.erb -# ---------------------------------------------------------------- -# Command definitions -# ---------------------------------------------------------------- - -<% Nagios.instance.commands.each do |key,command| -%> -<%= command.definition %> - -<% end -%> diff --git a/cookbooks/nagios/templates/default/contacts.cfg.erb b/cookbooks/nagios/templates/default/contacts.cfg.erb deleted file mode 100644 index 3961efd76..000000000 --- a/cookbooks/nagios/templates/default/contacts.cfg.erb +++ /dev/null @@ -1,37 +0,0 @@ -# $Id: Generated by chef for node: <%= node['hostname'] %> -# ---------------------------------------------------------------- -# NOTE: This file is controlled by chef templates! -# Do not edit or change this file but change the following: -# template file : contacts.cfg.erb -# ---------------------------------------------------------------- -# Contact definitions -# Contactgroup definitions -# ---------------------------------------------------------------- - -<% Nagios.instance.contacts.each do |key,contact| -%> -<% if contact.name.nil? # Skipping all the template contacts %> -<%= contact.definition %> - -<% end %> -<% end -%> - -<% Nagios.instance.contactgroups.each do |key,group| -%> -<% if group.name.nil? # Skipping all the template contactgroups %> -<%= group.definition %> - -<% end %> -<% end -%> - -<% Nagios.instance.serviceescalations.each do |escalation| -%> -<% if escalation.name.nil? # Skipping all the template serviceescalations %> -<%= escalation.definition %> - -<% end %> -<% end -%> - -<% Nagios.instance.hostescalations.each do |escalation| -%> -<% if escalation.name.nil? # Skipping all the template hostescalations %> -<%= escalation.definition %> - -<% end %> -<% end -%> diff --git a/cookbooks/nagios/templates/default/hostgroups.cfg.erb b/cookbooks/nagios/templates/default/hostgroups.cfg.erb deleted file mode 100644 index e5557d3ee..000000000 --- a/cookbooks/nagios/templates/default/hostgroups.cfg.erb +++ /dev/null @@ -1,25 +0,0 @@ -# $Id: Generated by chef for node: <%= node['hostname'] %> -# ---------------------------------------------------------------- -# NOTE: This file is controlled by chef templates! -# Do not edit or change this file but change the following: -# template file : hostgroups.cfg.erb -# ---------------------------------------------------------------- -# Hostgroup definitions -# ---------------------------------------------------------------- - -define hostgroup { - hostgroup_name all - alias all -<% if node['nagios']['regexp_matching'] == 1 -%> - members .* -<% else -%> - members * -<% end -%> -} - -<% Nagios.instance.hostgroups.each do |key,hostgroup| -%> -<% if hostgroup.name.nil? # Skipping all the template hosts %> -<%= hostgroup.definition unless hostgroup.hostgroup_name == 'all' %> - -<% end %> -<% end -%> diff --git a/cookbooks/nagios/templates/default/hosts.cfg.erb b/cookbooks/nagios/templates/default/hosts.cfg.erb deleted file mode 100644 index 55b4e4b4d..000000000 --- a/cookbooks/nagios/templates/default/hosts.cfg.erb +++ /dev/null @@ -1,15 +0,0 @@ -# $Id: Generated by chef for node: <%= node['hostname'] %> -# ---------------------------------------------------------------- -# NOTE: This file is controlled by chef templates! -# Do not edit or change this file but change the following: -# template file : hosts.cfg.erb -# ---------------------------------------------------------------- -# Host definitions -# ---------------------------------------------------------------- - -<% Nagios.instance.hosts.each do |key,host| -%> -<% if host.name.nil? # Skipping all the template hosts %> -<%= host.definition %> - -<% end %> -<% end -%> diff --git a/cookbooks/nagios/templates/default/htpasswd.users.erb b/cookbooks/nagios/templates/default/htpasswd.users.erb deleted file mode 100644 index 19b7af3e5..000000000 --- a/cookbooks/nagios/templates/default/htpasswd.users.erb +++ /dev/null @@ -1,6 +0,0 @@ -# Autogenerated by Chef. -<% @nagios_users.each do |user| -%> -<% if user["htpasswd"] && user["htpasswd"].length > 0 -%> -<%= user["id"] %>:<%= user["htpasswd"] %> -<% end -%> -<% end -%> diff --git a/cookbooks/nagios/templates/default/nagios.cfg.erb b/cookbooks/nagios/templates/default/nagios.cfg.erb deleted file mode 100644 index 6891bf115..000000000 --- a/cookbooks/nagios/templates/default/nagios.cfg.erb +++ /dev/null @@ -1,22 +0,0 @@ -# $Id: Autogenerated by Chef. -# ---------------------------------------------------------------- -# NOTE: This file is controlled by chef templates! -# Do not edit or change this file but change the following: -# cookbook : nagios -# template file : nagios.cfg.erb -# ---------------------------------------------------------------- -# Nagios Configuration -# ---------------------------------------------------------------- - -<% @nagios_config.each do |key,items| %> - <% case items %> - <% when String %> -<%= key + '=' + items %> - <% when Fixnum %> -<%= key + '=' + items.to_s %> - <% when Array %> - <% items.each do |item| %> -<%= key + '=' + item %> - <% end %> - <% end %> -<% end %> diff --git a/cookbooks/nagios/templates/default/nginx.conf.erb b/cookbooks/nagios/templates/default/nginx.conf.erb deleted file mode 100644 index 4db4190f0..000000000 --- a/cookbooks/nagios/templates/default/nginx.conf.erb +++ /dev/null @@ -1,62 +0,0 @@ -server { - - listen <%= @listen_port %>; - <% if @nagios_url %> - server_name <%= @nagios_url %>; - <% else %> - server_name <%= node['nagios']['server']['name'] %> <%= node['nagios']['server']['name'] %>.<%= @chef_env %>.<%= @public_domain %> <%= @fqdn %>; - <% end %> - access_log <%= File.join(@log_dir, 'nginx_access.log') %>; - error_log <%= File.join(@log_dir, 'nginx_error.log') %>; - root <%= @docroot %>; - <% unless node['nagios']['allowed_ips'].empty? -%> - satisfy any; - <%= node['nagios']['allowed_ips'].join(";\n")%>; - deny all; - <% end -%> - auth_basic "Nagios Server"; - auth_basic_user_file <%= @htpasswd_file %>; - index index.php index.html index.cgi; - -<% if @https %> - ssl on; - ssl_certificate <%= @ssl_cert_file %>; - ssl_certificate_key <%= @ssl_cert_key %>; - ssl_ciphers HIGH; - ssl_protocols TLSv1 TLSv1.1 TLSv1.2; - ssl_prefer_server_ciphers on; -<% end %> - -<% if @cgi %> -<%= -nginx_dispatch(:cgi, - :custom => %q( - fastcgi_param AUTH_USER $remote_user; - fastcgi_param REMOTE_USER $remote_user; - ) -) -%> -<% end %> - -<% if @php %> -<%= -nginx_dispatch(:php, - :docroot => @docroot, - :custom => %q( - fastcgi_param AUTH_USER $remote_user; - fastcgi_param REMOTE_USER $remote_user; - ) -) -%> -<% end %> - -<% if node['platform_family'] == 'debian' && node['nagios']['server']['install_method'] == 'package'-%> - location /stylesheets { - alias /etc/<%= node['nagios']['server']['vname'] %>/stylesheets; - } -<% end -%> - location / { - root <%= @docroot %>; - } - -} diff --git a/cookbooks/nagios/templates/default/pagerduty.cgi.erb b/cookbooks/nagios/templates/default/pagerduty.cgi.erb deleted file mode 100644 index b2d2d4a6c..000000000 --- a/cookbooks/nagios/templates/default/pagerduty.cgi.erb +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env perl - -use warnings; -use strict; - -use CGI; -use JSON; -use LWP::UserAgent; - -# ============================================================================= - -my $CONFIG = { - # Nagios/Ubuntu defaults - 'command_file' => '<%= @command_file %>', # External commands file - # Icinga/CentOS defaults - #'command_file' => '/var/spool/icinga/cmd/icinga.cmd', # External commands file - # Icinga acknowledgement TTL - 'ack_ttl' => 0, # Time in seconds the acknowledgement in Icinga last before - # it times out automatically. 0 means the acknowledgement - # never expires. If you're using Nagios this MUST be 0. -}; - -# ============================================================================= - -sub ackHost { - my ($time, $host, $comment, $author, $sticky, $notify, $persistent) = @_; - - # Open the external commands file - if (! open (NAGIOS, '>>', $CONFIG->{'command_file'})) { - # Well shizzle - return (undef, $!); - } - - # Success! Write the command - if ($CONFIG->{'ack_ttl'} <= 0) { - printf (NAGIOS "[%u] ACKNOWLEDGE_HOST_PROBLEM;%s;%u;%u;%u;%s;%s\n", $time, $host, $sticky, $notify, $persistent, $author, $comment); - - } else { - printf (NAGIOS "[%u] ACKNOWLEDGE_HOST_PROBLEM_EXPIRE;%s;%u;%u;%u;%u;%s;%s\n", $time, $host, $sticky, $notify, $persistent, ($time + $CONFIG->{'ack_ttl'}), $author, $comment); - } - # Close the file handle - close (NAGIOS); - - # Return with happiness - return (1, undef); -} - -# ============================================================================= - -sub deackHost { - my ($time, $host) = @_; - - # Open the external commands file - if (! open (NAGIOS, '>>', $CONFIG->{'command_file'})) { - # Well shizzle - return (undef, $!); - } - - # Success! Write the command - printf (NAGIOS "[%u] REMOVE_HOST_ACKNOWLEDGEMENT;%s\n", $time, $host); - # Close the file handle - close (NAGIOS); - - # Return with happiness - return (1, undef); -} - -# ============================================================================= - -sub ackService { - my ($time, $host, $service, $comment, $author, $sticky, $notify, $persistent) = @_; - - # Open the external commands file - if (! open (NAGIOS, '>>', $CONFIG->{'command_file'})) { - # Well shizzle - return (undef, $!); - } - - # Success! Write the command - if ($CONFIG->{'ack_ttl'} <= 0) { - printf (NAGIOS "[%u] ACKNOWLEDGE_SVC_PROBLEM;%s;%s;%u;%u;%u;%s;%s\n", $time, $host, $service, $sticky, $notify, $persistent, $author, $comment); - - } else { - printf (NAGIOS "[%u] ACKNOWLEDGE_SVC_PROBLEM_EXPIRE;%s;%s;%u;%u;%u;%u;%s;%s\n", $time, $host, $service, $sticky, $notify, $persistent, ($time + $CONFIG->{'ack_ttl'}), $author, $comment); - } - - # Close the file handle - close (NAGIOS); - - # Return with happiness - return (1, undef); -} - -# ============================================================================= - -sub deackService { - my ($time, $host, $service) = @_; - - # Open the external commands file - if (! open (NAGIOS, '>>', $CONFIG->{'command_file'})) { - # Well shizzle - return (undef, $!); - } - - # Success! Write the command - printf (NAGIOS "[%u] REMOVE_SVC_ACKNOWLEDGEMENT;%s;%s\n", $time, $host, $service); - # Close the file handle - close (NAGIOS); - - # Return with happiness - return (1, undef); -} - -# ============================================================================= - -my ($TIME, $QUERY, $POST, $JSON); - -$TIME = time (); - -$QUERY = CGI->new (); - -if (! defined ($POST = $QUERY->param ('POSTDATA'))) { - print ("Status: 400 Requests must be POSTs\n\n400 Requests must be POSTs\n"); - exit (0); -} - -if (! defined ($JSON = JSON->new ()->utf8 ()->decode ($POST))) { - print ("Status: 400 Request payload must be JSON blob\n\n400 Request payload must JSON blob\n"); - exit (0); -} - -if ((ref ($JSON) ne 'HASH') || ! defined ($JSON->{'messages'}) || (ref ($JSON->{'messages'}) ne 'ARRAY')) { - print ("Status: 400 JSON blob does not match the expected format\n\n400 JSON blob does not match expected format\n"); - exit (0); -} - -my ($message, $return); -$return = { - 'status' => 'okay', - 'messages' => {} -}; - -MESSAGE: foreach $message (@{$JSON->{'messages'}}) { - my ($hostservice, $status, $error); - - if ((ref ($message) ne 'HASH') || ! defined ($message->{'type'})) { - next MESSAGE; - } - - $hostservice = $message->{'data'}->{'incident'}->{'trigger_summary_data'}; - - if (! defined ($hostservice)) { - next MESSAGE; - } - - if ($message->{'type'} eq 'incident.acknowledge') { - if (! defined ($hostservice->{'SERVICEDESC'})) { - ($status, $error) = ackHost ($TIME, $hostservice->{'HOSTNAME'}, 'Acknowledged by PagerDuty', 'PagerDuty', 2, 0, 0); - - } else { - ($status, $error) = ackService ($TIME, $hostservice->{'HOSTNAME'}, $hostservice->{'SERVICEDESC'}, 'Acknowledged by PagerDuty', 'PagerDuty', 2, 0, 0); - } - - $return->{'messages'}{$message->{'id'}} = { - 'status' => ($status ? 'okay' : 'fail'), - 'message' => ($error ? $error : undef) - }; - - } elsif ($message->{'type'} eq 'incident.unacknowledge') { - if (! defined ($hostservice->{'SERVICEDESC'})) { - ($status, $error) = deackHost ($TIME, $hostservice->{'HOSTNAME'}); - - } else { - ($status, $error) = deackService ($TIME, $hostservice->{'HOSTNAME'}, $hostservice->{'SERVICEDESC'}); - } - - $return->{'messages'}->{$message->{'id'}} = { - 'status' => ($status ? 'okay' : 'fail'), - 'message' => ($error ? $error : undef) - }; - $return->{'status'} = ($status eq 'okay' ? $return->{'status'} : 'fail'); - } -} - -printf ("Status: 200 Okay\nContent-type: application/json\n\n%s\n", JSON->new ()->utf8 ()->encode ($return)); diff --git a/cookbooks/nagios/templates/default/resource.cfg.erb b/cookbooks/nagios/templates/default/resource.cfg.erb deleted file mode 100644 index 0b9c29080..000000000 --- a/cookbooks/nagios/templates/default/resource.cfg.erb +++ /dev/null @@ -1,27 +0,0 @@ -# $Id: Generated by chef for node: <%= node['hostname'] %> -# ---------------------------------------------------------------- -# NOTE: This file is controlled by chef templates! -# Do not edit or change this file but change the following: -# template file : resource.cfg.erb -# ---------------------------------------------------------------- -# You can define $USERx$ macros in this file, which can in turn be used -# in command definitions in your host config file(s). $USERx$ macros are -# useful for storing sensitive information such as usernames, passwords, -# etc. They are also handy for specifying the path to plugins and -# event handlers - if you decide to move the plugins or event handlers to -# a different directory in the future, you can just update one or two -# $USERx$ macros, instead of modifying a lot of command definitions. -# -# The CGIs will not attempt to read the contents of resource files, so -# you can set restrictive permissions (600 or 660) on them. -# -# Nagios supports up to 256 $USERx$ macros ($USER1$ through $USER256$) -# -# Resource files may also be used to store configuration directives for -# external data sources like MySQL... -# ---------------------------------------------------------------- - -<% Nagios.instance.resources.each do |key,res| -%> -<%= res.definition %> -<% end -%> - diff --git a/cookbooks/nagios/templates/default/servicedependencies.cfg.erb b/cookbooks/nagios/templates/default/servicedependencies.cfg.erb deleted file mode 100644 index a5862db3d..000000000 --- a/cookbooks/nagios/templates/default/servicedependencies.cfg.erb +++ /dev/null @@ -1,15 +0,0 @@ -# $Id: Generated by chef for node: <%= node['hostname'] %> -# ---------------------------------------------------------------- -# NOTE: This file is controlled by chef templates! -# Do not edit or change this file but change the following: -# template file : servicedependencies.cfg.erb -# ---------------------------------------------------------------- -# Service Dependency Definitions -# ---------------------------------------------------------------- - -<% Nagios.instance.servicedependencies.each do |key,dependency| -%> -<% if dependency.name.nil? # Skipping all the template servicedependencies %> -<%= dependency.definition %> - -<% end %> -<% end -%> diff --git a/cookbooks/nagios/templates/default/servicegroups.cfg.erb b/cookbooks/nagios/templates/default/servicegroups.cfg.erb deleted file mode 100644 index 6d405455d..000000000 --- a/cookbooks/nagios/templates/default/servicegroups.cfg.erb +++ /dev/null @@ -1,14 +0,0 @@ -# ---------------------------------------------------------------- -# NOTE: This file is controlled by chef templates! -# Do not edit or change this file but change the following: -# template file : servicegroups.cfg.erb -# ---------------------------------------------------------------- -# Servicegroup definitions -# ---------------------------------------------------------------- - -<% Nagios.instance.servicegroups.each do |key,servicegroup| -%> -<% if servicegroup.name.nil? # Skipping all the template servicegroups %> -<%= servicegroup.definition %> - -<% end %> -<% end -%> diff --git a/cookbooks/nagios/templates/default/services.cfg.erb b/cookbooks/nagios/templates/default/services.cfg.erb deleted file mode 100644 index a69c5fd40..000000000 --- a/cookbooks/nagios/templates/default/services.cfg.erb +++ /dev/null @@ -1,14 +0,0 @@ -# ---------------------------------------------------------------- -# NOTE: This file is controlled by chef templates! -# Do not edit or change this file but change the following: -# template file : services.cfg.erb -# ---------------------------------------------------------------- -# Service definitions -# ---------------------------------------------------------------- - -<% Nagios.instance.services.each do |key,service| -%> -<% if service.name.nil? # Skipping all the template services %> -<%= service.definition %> - -<% end %> -<% end -%> diff --git a/cookbooks/nagios/templates/default/templates.cfg.erb b/cookbooks/nagios/templates/default/templates.cfg.erb deleted file mode 100644 index 318423da6..000000000 --- a/cookbooks/nagios/templates/default/templates.cfg.erb +++ /dev/null @@ -1,31 +0,0 @@ -# $Id: Generated by chef for node: <%= node['hostname'] %> -# ---------------------------------------------------------------- -# NOTE: This file is controlled by chef templates! -# Do not edit or change this file but change the following: -# template file : templates.cfg.erb -# ---------------------------------------------------------------- -# Contact definitions -# Host definitions -# Service definitions -# ---------------------------------------------------------------- - -<% Nagios.instance.contacts.each do |key,contact| -%> -<% if contact.name # Only get all the template contacts %> -<%= contact.definition %> - -<% end %> -<% end -%> - -<% Nagios.instance.hosts.each do |key,host| -%> -<% if host.name # Only get all the template hosts %> -<%= host.definition %> - -<% end %> -<% end -%> - -<% Nagios.instance.services.each do |key,service| -%> -<% if service.name # Only get all the template services %> -<%= service.definition %> - -<% end %> -<% end -%> diff --git a/cookbooks/nagios/templates/default/timeperiods.cfg.erb b/cookbooks/nagios/templates/default/timeperiods.cfg.erb deleted file mode 100644 index 8b1bb508e..000000000 --- a/cookbooks/nagios/templates/default/timeperiods.cfg.erb +++ /dev/null @@ -1,13 +0,0 @@ -# $Id: Generated by chef for node: <%= node['hostname'] %> -# ---------------------------------------------------------------- -# NOTE: This file is controlled by chef templates! -# Do not edit or change this file but change the following: -# template file : timeperiods.cfg.erb -# ---------------------------------------------------------------- -# Time period definitions -# ---------------------------------------------------------------- - -<% Nagios.instance.timeperiods.each do |entry,timeperiod| -%> -<%= timeperiod.definition %> - -<% end -%> diff --git a/extras/alpha.png b/extras/alpha.png new file mode 100644 index 000000000..73e14f16f Binary files /dev/null and b/extras/alpha.png differ diff --git a/extras/beta.png b/extras/beta.png new file mode 100644 index 000000000..201523430 Binary files /dev/null and b/extras/beta.png differ diff --git a/extras/clean-stock-amis b/extras/clean-stock-amis old mode 100755 new mode 100644 index d99c8db9f..ed41b5e36 --- a/extras/clean-stock-amis +++ b/extras/clean-stock-amis @@ -18,16 +18,22 @@ require 'json' require File.realpath(File.expand_path(File.dirname(__FILE__)+"/../bin/mu-load-config.rb")) require 'mu' +credentials = if ARGV[0] and !ARGV[0].empty? + ARGV[0] +else + nil +end + filters = [ { name: "owner-id", - values: [MU.account_number] + values: [MU::Cloud::AWS.credToAcct(credentials)] } ] MU::Cloud::AWS.listRegions.each { | r| - images = MU::Cloud::AWS.ec2(r).describe_images( + images = MU::Cloud::AWS.ec2(region: r, credentials: credentials).describe_images( filters: filters + [{ "name" => "state", "values" => ["available"]}] ).images images.each { |ami| @@ -39,9 +45,9 @@ MU::Cloud::AWS.listRegions.each { | r| end } MU.log "Deregistering #{ami.name} (#{ami.creation_date})", MU::WARN, details: snaps - MU::Cloud::AWS.ec2(r).deregister_image(image_id: ami.image_id) - snaps.each { |snap_id| - MU::Cloud::AWS.ec2(r).delete_snapshot(snapshot_id: snap_id) + MU::Cloud::AWS.ec2(region: r, credentials: credentials).deregister_image(image_id: ami.image_id) + snaps.each { |snap_id| + MU::Cloud::AWS.ec2(region: r, credentials: credentials).delete_snapshot(snapshot_id: snap_id) } end } diff --git a/extras/generate-stock-images b/extras/generate-stock-images new file mode 100644 index 000000000..3bb117083 --- /dev/null +++ b/extras/generate-stock-images @@ -0,0 +1,131 @@ +#!/usr/local/ruby-current/bin/ruby +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +require File.realpath(File.expand_path(File.dirname(__FILE__)+"/../bin/mu-load-config.rb")) +# now we have our global config available as the read-only hash $MU_CFG + +require 'rubygems' +require 'bundler/setup' +require 'optimist' +require 'mu' + +bok_dir = MU.myRoot+"/extras/image-generators" + +available_clouds = {} +Dir.foreach(bok_dir) { |d| + next if d == "." or d == ".." + next if !Dir.exist?(MU.myRoot+"/extras/image-generators/"+d) + available_clouds[d] = [] + Dir.foreach(bok_dir+"/"+d) { |yamlfile| + next if !yamlfile.match(/(.+?)\.yaml$/) + platform = Regexp.last_match[1] + available_clouds[d] << platform + } +} + +$opts = Optimist::options do + banner <<-EOS +Usage: +#{$0} [-c ] [-p ] + EOS + opt :clouds, "Clouds for which to generate images", :require => false, :type => :strings, :default => available_clouds.keys + opt :platforms, "Platforms for which to generate images", :require => false, :type => :strings, :default => available_clouds.values.flatten.sort.uniq + opt :environment, "Environment with which to tag our generated images.", :require => false, :type => :string, :default => "prod" + opt :age, "Minimum age, in days, at which we will replace existing images. Set to 0 to force a new build regardless of age.", :require => false, :type => :integer, :default => 30 + if available_clouds.keys.include?("AWS") + opt :upload_to, "AWS S3 bucket and path to which we should upload our updated image list.", :require => false, :type => :string, :default => "s3://"+MU::Cloud::BASE_IMAGE_BUCKET+MU::Cloud::BASE_IMAGE_PATH + end + available_clouds.keys.each { |cloud| + opt (cloud.downcase+"_creds").to_sym, "Credentials to use when creating images in #{cloud}.", :require => false, :type => :string + } + opt :dryrun, "Don't actually run our deploy.", :require => false, :type => :boolean, :default => false +end + +pwd = Dir.pwd + +if !available_clouds.keys.include?("AWS") # XXX or if we don't have permissions to write $opt[:upload_to] + MU.log "No AWS credentials available- I have nowhere to upload new imaged lists. Will print to STDOUT instead.", MU::WARN +end + +now = DateTime.now + +exitcode = 0 +$opts[:clouds].each { |cloud| + current_images = MU::Cloud.getStockImage(cloud, fail_hard: true) + $opts[:platforms].each { |platform| + if File.exists?(bok_dir+"/"+cloud+"/"+platform+".yaml") + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud).const_get("Server") + if current_images[platform].is_a?(String) + age = cloudclass.imageTimeStamp(current_images[platform]) + if (now - age) >= $opts[:age] + MU.log "#{cloud} image for #{platform} was last built #{age.to_s}, refreshing", MU::NOTICE + else + next + end + else + needed = false + if !current_images[platform] + needed = true + else + current_images[platform].each_pair { |r, img_id| + age = cloudclass.imageTimeStamp(img_id, region: r) + if (now - age) >= $opts[:age] + MU.log "#{cloud} image for #{platform} was last built #{age.to_s}, refreshing", MU::NOTICE + needed = true + break + end + } + end + next if !needed + end + conf_engine = MU::Config.new( + bok_dir+"/"+cloud+"/"+platform+".yaml", + default_credentials: $opts[(cloud.downcase+"_creds").to_sym] + ) + stack_conf = conf_engine.config + + if $opts[:dryrun] + puts stack_conf.to_yaml + else + begin + deployer = MU::Deploy.new( + $opts[:environment], + stack_conf: stack_conf + ) + deployer.run + MU.log "New images for #{cloud}:#{platform}", MU::NOTICE, details: deployer.mommacat.deployment['images'] + current_images[platform] ||= {} + current_images.deep_merge!(deployer.mommacat.deployment['images']) + + # Scrub any loose metadata left over from our image deployment. It's + # ok, this won't touch the images we just made. + MU::Cleanup.run(deployer.mommacat.deploy_id, skipsnapshots: true, verbosity: MU::Logger::QUIET) + rescue Exception => e + MU.log e.message, MU::ERR + exitcode = 1 + end + end + end + } + + if !available_clouds.keys.include?("AWS") # XXX or if we don't have permissions + puts current_images.to_yaml + elsif !$opts[:dryrun] + MU::Cloud::AWS::Bucket.upload($opts[:upload_to]+"/"+cloud+".yaml", data: current_images.to_yaml, credentials: $opts[:aws_creds], acl: "public-read") + end +} + +exit exitcode diff --git a/extras/git-fix-permissions-hook b/extras/git-fix-permissions-hook old mode 100755 new mode 100644 diff --git a/extras/image-generators/AWS/centos6.yaml b/extras/image-generators/AWS/centos6.yaml new file mode 100644 index 000000000..366bace78 --- /dev/null +++ b/extras/image-generators/AWS/centos6.yaml @@ -0,0 +1,17 @@ +--- +appname: mu +servers: +- name: centos6 + platform: centos6 + size: m3.medium + scrub_groomer: true + run_list: + - recipe[mu-tools::cloudinit] + - recipe[mu-tools::apply_security] + - recipe[mu-tools::updates] + - recipe[mu-tools::split_var_partitions] + create_image: + image_then_destroy: true + public: true + copy_to_regions: + - "#ALL" diff --git a/extras/image-generators/aws/centos7-govcloud.yaml b/extras/image-generators/AWS/centos7-govcloud.yaml similarity index 100% rename from extras/image-generators/aws/centos7-govcloud.yaml rename to extras/image-generators/AWS/centos7-govcloud.yaml diff --git a/extras/image-generators/aws/centos7.yaml b/extras/image-generators/AWS/centos7.yaml similarity index 100% rename from extras/image-generators/aws/centos7.yaml rename to extras/image-generators/AWS/centos7.yaml diff --git a/extras/image-generators/aws/rhel7.yaml b/extras/image-generators/AWS/rhel7.yaml similarity index 100% rename from extras/image-generators/aws/rhel7.yaml rename to extras/image-generators/AWS/rhel7.yaml diff --git a/extras/image-generators/aws/win2k12.yaml b/extras/image-generators/AWS/win2k12.yaml similarity index 100% rename from extras/image-generators/aws/win2k12.yaml rename to extras/image-generators/AWS/win2k12.yaml diff --git a/extras/image-generators/aws/win2k16.yaml b/extras/image-generators/AWS/win2k16.yaml similarity index 100% rename from extras/image-generators/aws/win2k16.yaml rename to extras/image-generators/AWS/win2k16.yaml diff --git a/extras/image-generators/aws/windows.yaml b/extras/image-generators/AWS/windows.yaml similarity index 100% rename from extras/image-generators/aws/windows.yaml rename to extras/image-generators/AWS/windows.yaml diff --git a/extras/image-generators/gcp/centos6.yaml b/extras/image-generators/Google/centos6.yaml similarity index 91% rename from extras/image-generators/gcp/centos6.yaml rename to extras/image-generators/Google/centos6.yaml index 69b4b8d8a..5b26ae5e3 100644 --- a/extras/image-generators/gcp/centos6.yaml +++ b/extras/image-generators/Google/centos6.yaml @@ -3,6 +3,7 @@ servers: - name: centos6 cloud: Google + image_id: "centos-cloud/centos-6" platform: centos6 ssh_user: centos size: g1-small diff --git a/extras/image-generators/Google/centos7.yaml b/extras/image-generators/Google/centos7.yaml new file mode 100644 index 000000000..716bacaa9 --- /dev/null +++ b/extras/image-generators/Google/centos7.yaml @@ -0,0 +1,18 @@ +--- + appname: mu + servers: + - name: centos7 + cloud: Google + image_id: "centos-cloud/centos-7" + platform: centos6 + ssh_user: centos + size: g1-small + associate_public_ip: true + run_list: + - recipe[mu-tools::cloudinit] + - recipe[mu-tools::apply_security] + - recipe[mu-tools::updates] + - recipe[mu-tools::split_var_partitions] + create_image: + image_then_destroy: true + public: true diff --git a/extras/image-generators/aws/centos6.yaml b/extras/image-generators/aws/centos6.yaml deleted file mode 100644 index 56a21278d..000000000 --- a/extras/image-generators/aws/centos6.yaml +++ /dev/null @@ -1,18 +0,0 @@ ---- - appname: mu - servers: - - - name: centos6 - platform: centos6 - size: m3.medium - scrub_groomer: true - run_list: - - recipe[mu-tools::cloudinit] - - recipe[mu-tools::apply_security] - - recipe[mu-tools::updates] - - recipe[mu-tools::split_var_partitions] - create_image: - image_then_destroy: true - public: true - copy_to_regions: - - "#ALL" diff --git a/extras/list-stock-amis b/extras/list-stock-amis new file mode 100644 index 000000000..bc06d2a5e --- /dev/null +++ b/extras/list-stock-amis @@ -0,0 +1,64 @@ +#!/usr/local/ruby-current/bin/ruby +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'optimist' +require 'json' +require 'yaml' +require File.realpath(File.expand_path(File.dirname(__FILE__)+"/../bin/mu-load-config.rb")) +require 'mu' + +credentials = if ARGV[0] and !ARGV[0].empty? + ARGV[0] +else + nil +end + +filters = [ + { + name: "owner-id", + values: [MU::Cloud::AWS.credToAcct(credentials)] + } +] + +platforms = {} + +MU::Cloud::AWS.listRegions.each { | r| + images = MU::Cloud::AWS.ec2(region: r, credentials: credentials).describe_images( + filters: filters + [{ "name" => "state", "values" => ["available"]}] + ).images + images.each { |ami| + if (DateTime.now.to_time - DateTime.parse(ami.creation_date).to_time) < 15552000 and ami.name.match(/^MU-PROD-\d{10}-[A-Z]{2}-(.*)/) + platform = Regexp.last_match[1].downcase + next if !platform + platforms[platform] ||= {} + if !platforms[platform][r] or + DateTime.parse(ami.creation_date).to_time > platforms[platform][r]['date'] + platforms[platform][r] = { + "date" => DateTime.parse(ami.creation_date).to_time, + "ami" => ami.image_id, + "name" => ami.name + } + end + end + } +} + +platforms.each_pair { |p, r_data| + r_data.each_pair { |r, data| + r_data[r] = data["ami"] + } +} + +puts platforms.to_yaml diff --git a/extras/python_rpm/build.sh b/extras/python_rpm/build.sh new file mode 100644 index 000000000..004e09423 --- /dev/null +++ b/extras/python_rpm/build.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +rpm -q rpm-build || yum -y install rpm-build + +base="/opt/mu/lib/extras/python_rpm" + +for d in BUILD BUILDROOT RPMS SOURCES SPECS SRPMS;do + mkdir -p ~/rpmbuild/$d +done +cd ~/rpmbuild + +echo "Temporarily deleting /usr/local/python-current so rpmbuild can create it" +link="`readlink /usr/local/python-current`" +rm -f /usr/local/python-current +chmod 000 /usr/bin/python # otherwise this brain-dead build system tries to compile parts of itself with the wrong executable +env -i PATH="/bin:/usr/bin" /usr/bin/rpmbuild -ba $base/muthon.spec +chmod 755 /usr/bin/python +find ~/rpmbuild/ -type f -name 'muthon*' -exec ls -la {} \; +if [ "$link" != "" ];then + ln -s "$link" /usr/local/python-current +fi diff --git a/extras/python_rpm/muthon.spec b/extras/python_rpm/muthon.spec new file mode 100644 index 000000000..0fc94daeb --- /dev/null +++ b/extras/python_rpm/muthon.spec @@ -0,0 +1,68 @@ +Summary: Python for Mu +BuildArch: x86_64 +Name: muthon +Version: 2.7.16 +Release: 1%{dist} +Group: Development/Languages +License: Ruby License/GPL - see COPYING +URL: https://www.python.org/ +Prefix: /opt/pythons +Source: https://www.python.org/ftp/python/%{version}/Python-%{version}.tgz + +# auto-require inserts nonsensical things, like a dependency on our own +# executable, so I guess we'll declare dependencies by package ourselves +AutoReq: no +# XXX these don't work for some reason +#%global __requires_exclude ^/usr/local/bin/python$ +#%global __requires_exclude ^/opt/pythons/Python-%{version}/bin/python.*$ + +BuildRequires: zlib-devel +BuildRequires: tcl-devel +BuildRequires: gdbm-devel +BuildRequires: openssl-devel +BuildRequires: sqlite-devel +BuildRequires: tk-devel +Requires: zlib +Requires: gdbm +Requires: tcl +Requires: openssl +Requires: glibc +Requires: ncurses-libs +Requires: sqlite +Requires: tk + +%description +I was sober when I wrote this spec file + +%prep +rm -rf $RPM_BUILD_DIR/Python-%{version} +rm -rf %{prefix} +test -f $RPM_SOURCE_DIR/Python-%{version}.tgz || ( cd $RPM_SOURCE_DIR && curl -O https://www.python.org/ftp/python/%{version}/Python-%{version}.tgz ) +curl https://bootstrap.pypa.io/get-pip.py -o $RPM_SOURCE_DIR/get-pip.py +tar -xzvf $RPM_SOURCE_DIR/Python-%{version}.tgz +mkdir -p $RPM_BUILD_ROOT%{prefix} +rm -rf $RPM_BUILD_ROOT%{prefix}/Python-%{version} +ln -s %{prefix}/Python-%{version} $RPM_BUILD_ROOT%{prefix}/Python-%{version} + +%build +cd $RPM_BUILD_DIR/Python-%{version} +mkdir -p %{prefix}/Python-%{version} +env -i PATH="/bin:/usr/bin" ./configure --prefix=%{prefix}/Python-%{version} --exec-prefix=%{prefix}/Python-%{version} --enable-shared LDFLAGS=-Wl,-rpath=%{prefix}/Python-%{version}/lib +env -i PATH="/bin:/usr/bin" make + +%install +cd $RPM_BUILD_DIR/Python-%{version} +env -i PATH="/bin:/usr/bin" make install +%{prefix}/Python-%{version}/bin/python $RPM_SOURCE_DIR/get-pip.py --prefix %{prefix}/Python-%{version}/ +mkdir -p $RPM_BUILD_ROOT%{prefix} +mv %{prefix}/Python-%{version} $RPM_BUILD_ROOT%{prefix}/ +mkdir -p $RPM_BUILD_ROOT/usr/local/ +ln -s %{prefix}/Python-%{version} $RPM_BUILD_ROOT/usr/local/python-current + +%clean +cd $RPM_BUILD_DIR/Python-%{version} +make clean + +%files +%{prefix}/Python-%{version}/* +/usr/local/python-current diff --git a/extras/release.png b/extras/release.png new file mode 100644 index 000000000..13e72f60c Binary files /dev/null and b/extras/release.png differ diff --git a/extras/ruby_rpm/build.sh b/extras/ruby_rpm/build.sh old mode 100755 new mode 100644 diff --git a/extras/ruby_rpm/muby.spec b/extras/ruby_rpm/muby.spec index 3f7c66325..84d1e4679 100644 --- a/extras/ruby_rpm/muby.spec +++ b/extras/ruby_rpm/muby.spec @@ -1,7 +1,7 @@ Summary: Ruby for Mu(by) BuildArch: x86_64 Name: muby -Version: 2.5.3 +Version: 2.5.5 Release: 1%{dist} Group: Development/Languages License: Ruby License/GPL - see COPYING diff --git a/extras/vault_tools/export_vaults.sh b/extras/vault_tools/export_vaults.sh old mode 100755 new mode 100644 diff --git a/extras/vault_tools/recreate_vaults.sh b/extras/vault_tools/recreate_vaults.sh old mode 100755 new mode 100644 diff --git a/extras/vault_tools/test_vaults.sh b/extras/vault_tools/test_vaults.sh old mode 100755 new mode 100644 diff --git a/install/README.md b/install/README.md index 3d17f0bca..2564c7995 100644 --- a/install/README.md +++ b/install/README.md @@ -1,5 +1,46 @@ # Cloudamatic Mu Master Installation -Create a VPC and manually provision a Mu Master. Creation gives you full control over the shape of the master VPC and individual settings +There are two paths to creating a Mu Master. _Typical Installation_ and _CloudFormation Installation_ -For detailed instructions on installation techniques see [our Wiki Installation page](https://github.com/cloudamatic/mu/wiki/Install-Home) -For mu master usage instructions see [our Wiki usage page](https://github.com/cloudamatic/mu/wiki/Usage) +## Typical Instalation +In the standard instsatation create your original VPC and manually provision a Mu Master instance. + +### Prerequisites +1. Fully configured networking for the Mu Master + * Must have access to the internet + * Must manually configure any security on the networking +1. Properly configured instance + * Supported OS `CentOS 6-7`, `RHEL 6-7`, or `Amazon Linux 2` + * API credentials to grant proper Mu-Master permissions. (Cloud provider roles recomended when hosted in the same cloud you intend to work in.) + +### Installation + +**To Install From Master** +``` +curl https://raw.githubusercontent.com/cloudamatic/mu/master/install/installer > installer +chmod +x installer +./installer +``` + +**To Install From Development or Other Branch** +``` +curl https://raw.githubusercontent.com/cloudamatic/mu/development/install/installer > installer +chmod +x installer +MU_BRANCH=development ./installer +``` + +**Silent Install** +``` +TODO: @zr2d2 +``` +>For detailed instructions on installation techniques see [our Wiki Installation page](https://github.com/cloudamatic/mu/wiki/Install-Home) + +## CloudFormation Installation +> This method is depricated and may be removed from future releases + +The simplest path is to use our CloudFormation script to configure an appropriate Virtual Private Cloud and master with all features enabled. + +### Get Started by Clicking the Launch Button!! + +[![Launch Stack](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/new?stackName=CloudamaticInstaller&templateURL=https://s3.amazonaws.com/mu-cfn-installer/cfn_create_mu_master.json) + +>All AWS resources Created in `us-east-1` region. diff --git a/install/deprecated-bash-library.sh b/install/deprecated-bash-library.sh old mode 100755 new mode 100644 diff --git a/install/installer b/install/installer old mode 100755 new mode 100644 index 0dba7cc16..49f8f0b0d --- a/install/installer +++ b/install/installer @@ -2,7 +2,7 @@ BOLD=`tput bold` NORM=`tput sgr0` -CHEF_CLIENT_VERSION="14.11.21" +CHEF_CLIENT_VERSION="14.13.11" if [ "$MU_BRANCH" == "" ];then MU_BRANCH="master" mydir="`dirname $0`" diff --git a/install/jenkinskeys.rb b/install/jenkinskeys.rb old mode 100755 new mode 100644 diff --git a/install/mu-master.yaml b/install/mu-master.yaml new file mode 100644 index 000000000..83773bf11 --- /dev/null +++ b/install/mu-master.yaml @@ -0,0 +1,55 @@ +--- +appname: mu +parameters: +- name: cloud + default: <%= MU.myCloud || "AWS" %> + valid_values: +<% MU::Cloud.availableClouds.each { |c| %> + - <%= c %> +<% } %> +- name: public + default: true +- name: name + default: mu-master +scrub_mu_isms: true +servers: +- name: <%= name %> + groomer: Ansible + run_list: + - mu-installer + platform: centos7 + cloud: <%= cloud %> +<% if cloud == "AWS" %> + size: t2.medium +<% elsif cloud == "Azure" %> + size: Standard_DS1_v2 +<% elsif cloud == "Google" %> + size: n1-standard-1 +<% end %> + vpc: + name: <%= name %>-vpc +<% if public == "true" %> + subnet_pref: public + static_ip: + assign_ip: true + associate_public_ip: true +<% else %> + subnet_pref: private +<% end %> +<% if cloud == "AWS" %> + canned_iam_policies: + - AdministratorAccess +<% elsif cloud == "Azure" %> + roles: + - Owner +<% elsif cloud == "Google" %> + roles: + - role: + id: roles/owner +<% end %> +vpcs: +- name: <%= name %>-vpc + cloud: <%= cloud %> +<% if public %> + create_bastion: false +<% end %> diff --git a/install/user-dot-murc.erb b/install/user-dot-murc.erb index adfe0f37c..ade4d40e3 100644 --- a/install/user-dot-murc.erb +++ b/install/user-dot-murc.erb @@ -1,6 +1,6 @@ export MU_DATADIR="<%= home %>/.mu/var" export MU_CHEF_CACHE="<%= home %>/.chef" -export PATH="<%= installdir %>/bin:/usr/local/ruby-current/bin:${PATH}:/opt/opscode/embedded/bin" +export PATH="<%= installdir %>/bin:/usr/local/ruby-current/bin:/usr/local/python-current/bin:${PATH}:/opt/opscode/embedded/bin" if [ ! -f "<%= home %>/.first_chef_upload" -a "`tty`" != "not a tty" ];then touch "<%= home %>/.first_chef_upload" diff --git a/modules/Gemfile b/modules/Gemfile index 60477ac6e..a86ed5d39 100644 --- a/modules/Gemfile +++ b/modules/Gemfile @@ -25,8 +25,7 @@ gemspec :path => "../", :name => "cloud-mu" #gem 'color' gem 'rack' gem 'thin' -gem 'berkshelf', '~> 7.0.6' -gem 'pg', '~> 0.18.4' +gem 'berkshelf', '~> 7.0' gem 'mysql2' gem 'ruby-wmi' gem 'chef-vault', "~> 3.3.0" diff --git a/modules/Gemfile.lock b/modules/Gemfile.lock index b2a73b866..0a8082db0 100644 --- a/modules/Gemfile.lock +++ b/modules/Gemfile.lock @@ -10,15 +10,16 @@ GIT PATH remote: .. specs: - cloud-mu (2.0.3) + cloud-mu (3.0.0beta) addressable (~> 2.5) aws-sdk-core (< 3) + azure_sdk (~> 0.37) bundler (~> 1.17) chronic_duration (~> 0.10) color (~> 1.8) colorize (~> 0.8) erubis (~> 2.7) - google-api-client (~> 0.28.4) + google-api-client (~> 0.30.8) googleauth (~> 0.6) inifile (~> 3.0) json-schema (~> 2.8) @@ -26,7 +27,7 @@ PATH net-ssh (~> 4.2) net-ssh-multi (~> 1.2, >= 1.2.1) netaddr (~> 2.0) - nokogiri (~> 1.10.4) + nokogiri (~> 1.10) optimist (~> 3.0) rubocop (~> 0.58) ruby-graphviz (~> 1.2) @@ -42,11 +43,400 @@ GEM public_suffix (>= 2.0.2, < 4.0) ast (2.4.0) aws-eventstream (1.0.3) - aws-sdk-core (2.11.342) + aws-sdk-core (2.11.384) aws-sigv4 (~> 1.0) jmespath (~> 1.0) aws-sigv4 (1.1.0) aws-eventstream (~> 1.0, >= 1.0.2) + azure-core (0.1.15) + faraday (~> 0.9) + faraday_middleware (~> 0.10) + nokogiri (~> 1.6) + azure-storage (0.14.0.preview) + azure-core (~> 0.1) + faraday (~> 0.9) + faraday_middleware (~> 0.10) + nokogiri (~> 1.6, >= 1.6.8) + azure_cognitiveservices_anomalydetector (0.17.0) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_autosuggest (0.17.1) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_computervision (0.20.1) + ms_rest_azure (~> 0.11.1) + azure_cognitiveservices_contentmoderator (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_customimagesearch (0.17.1) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_customsearch (0.18.1) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_customvisionprediction (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_customvisiontraining (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_entitysearch (0.18.1) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_face (0.19.0) + ms_rest_azure (~> 0.11.1) + azure_cognitiveservices_formrecognizer (0.17.1) + ms_rest_azure (~> 0.11.1) + azure_cognitiveservices_imagesearch (0.18.1) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_localsearch (0.17.1) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_luisauthoring (0.17.3) + ms_rest_azure (~> 0.11.1) + azure_cognitiveservices_luisruntime (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_newssearch (0.18.1) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_personalizer (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_cognitiveservices_qnamaker (0.17.2) + ms_rest_azure (~> 0.11.1) + azure_cognitiveservices_qnamakerruntime (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_cognitiveservices_spellcheck (0.18.1) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_textanalytics (0.17.3) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_videosearch (0.18.1) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_visualsearch (0.18.1) + ms_rest_azure (~> 0.11.0) + azure_cognitiveservices_websearch (0.18.1) + ms_rest_azure (~> 0.11.0) + azure_event_grid (0.18.0) + ms_rest_azure (~> 0.11.1) + azure_graph_rbac (0.17.1) + ms_rest_azure (~> 0.11.0) + azure_key_vault (0.17.3) + ms_rest_azure (~> 0.11.0) + azure_mgmt_adhybridhealth_service (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_advisor (0.17.0) + ms_rest_azure (~> 0.11.0) + azure_mgmt_alerts_management (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_analysis_services (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_api_management (0.18.4) + ms_rest_azure (~> 0.11.1) + azure_mgmt_appconfiguration (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_attestation (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_authorization (0.18.4) + ms_rest_azure (~> 0.11.0) + azure_mgmt_automation (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_azurestack (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_batch (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_batchai (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_billing (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_bot_service (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_cdn (0.17.3) + ms_rest_azure (~> 0.11.0) + azure_mgmt_cognitive_services (0.18.3) + ms_rest_azure (~> 0.11.1) + azure_mgmt_commerce (0.17.1) + ms_rest_azure (~> 0.11.0) + azure_mgmt_compute (0.18.7) + ms_rest_azure (~> 0.11.1) + azure_mgmt_consumption (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_container_instance (0.17.4) + ms_rest_azure (~> 0.11.0) + azure_mgmt_container_registry (0.18.3) + ms_rest_azure (~> 0.11.1) + azure_mgmt_container_service (0.19.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_cosmosdb (0.18.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_cost_management (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_customer_insights (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_data_factory (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_data_migration (0.17.3) + ms_rest_azure (~> 0.11.1) + azure_mgmt_databox (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_datalake_analytics (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_datalake_store (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_datashare (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_dev_spaces (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_devtestlabs (0.18.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_dns (0.17.4) + ms_rest_azure (~> 0.11.0) + azure_mgmt_edgegateway (0.18.0) + ms_rest_azure (~> 0.11.0) + azure_mgmt_event_grid (0.17.6) + ms_rest_azure (~> 0.11.1) + azure_mgmt_event_hub (0.17.3) + ms_rest_azure (~> 0.11.0) + azure_mgmt_features (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_hanaonazure (0.17.1) + ms_rest_azure (~> 0.11.1) + azure_mgmt_hdinsight (0.17.5) + ms_rest_azure (~> 0.11.1) + azure_mgmt_iot_central (0.17.3) + ms_rest_azure (~> 0.11.0) + azure_mgmt_iot_hub (0.17.3) + ms_rest_azure (~> 0.11.1) + azure_mgmt_key_vault (0.17.4) + ms_rest_azure (~> 0.11.0) + azure_mgmt_kusto (0.18.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_labservices (0.17.1) + ms_rest_azure (~> 0.11.0) + azure_mgmt_links (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_locks (0.17.3) + ms_rest_azure (~> 0.11.0) + azure_mgmt_logic (0.18.1) + ms_rest_azure (~> 0.11.0) + azure_mgmt_machine_learning (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_machine_learning_services (0.17.2) + ms_rest_azure (~> 0.11.1) + azure_mgmt_managed_applications (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_mariadb (0.17.2) + ms_rest_azure (~> 0.11.1) + azure_mgmt_marketplace_ordering (0.17.4) + ms_rest_azure (~> 0.11.0) + azure_mgmt_media_services (0.19.0) + ms_rest_azure (~> 0.11.0) + azure_mgmt_migrate (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_mixedreality (0.17.2) + ms_rest_azure (~> 0.11.1) + azure_mgmt_monitor (0.17.4) + ms_rest_azure (~> 0.11.0) + azure_mgmt_msi (0.17.1) + ms_rest_azure (~> 0.11.0) + azure_mgmt_mysql (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_netapp (0.18.1) + ms_rest_azure (~> 0.11.1) + azure_mgmt_network (0.21.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_notification_hubs (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_operational_insights (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_operations_management (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_peering (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_policy (0.17.6) + ms_rest_azure (~> 0.11.1) + azure_mgmt_policy_insights (0.17.4) + ms_rest_azure (~> 0.11.0) + azure_mgmt_postgresql (0.17.1) + ms_rest_azure (~> 0.11.1) + azure_mgmt_powerbi_dedicated (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_powerbi_embedded (0.17.1) + ms_rest_azure (~> 0.11.0) + azure_mgmt_privatedns (0.17.1) + ms_rest_azure (~> 0.11.0) + azure_mgmt_recovery_services (0.17.3) + ms_rest_azure (~> 0.11.0) + azure_mgmt_recovery_services_backup (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_recovery_services_site_recovery (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_redis (0.17.3) + ms_rest_azure (~> 0.11.0) + azure_mgmt_relay (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_reservations (0.18.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_resourcegraph (0.17.1) + ms_rest_azure (~> 0.11.1) + azure_mgmt_resources (0.17.7) + ms_rest_azure (~> 0.11.1) + azure_mgmt_resources_management (0.17.1) + ms_rest_azure (~> 0.11.0) + azure_mgmt_scheduler (0.17.1) + ms_rest_azure (~> 0.11.0) + azure_mgmt_search (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_security (0.18.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_serialconsole (0.17.0) + ms_rest_azure (~> 0.11.0) + azure_mgmt_service_bus (0.17.3) + ms_rest_azure (~> 0.11.0) + azure_mgmt_service_fabric (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_signalr (0.17.4) + ms_rest_azure (~> 0.11.1) + azure_mgmt_sql (0.18.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_sqlvirtualmachine (0.18.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_stor_simple8000_series (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_storage (0.18.1) + ms_rest_azure (~> 0.11.1) + azure_mgmt_storagecache (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_storagesync (0.17.0) + ms_rest_azure (~> 0.11.0) + azure_mgmt_stream_analytics (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_subscriptions (0.18.1) + ms_rest_azure (~> 0.11.1) + azure_mgmt_traffic_manager (0.17.2) + ms_rest_azure (~> 0.11.0) + azure_mgmt_vmware_cloudsimple (0.17.0) + ms_rest_azure (~> 0.11.1) + azure_mgmt_web (0.17.4) + ms_rest_azure (~> 0.11.0) + azure_sdk (0.38.0) + azure-storage (~> 0.14.0.preview) + azure_cognitiveservices_anomalydetector (~> 0.17.0) + azure_cognitiveservices_autosuggest (~> 0.17.1) + azure_cognitiveservices_computervision (~> 0.20.1) + azure_cognitiveservices_contentmoderator (~> 0.17.2) + azure_cognitiveservices_customimagesearch (~> 0.17.1) + azure_cognitiveservices_customsearch (~> 0.18.1) + azure_cognitiveservices_customvisionprediction (~> 0.17.2) + azure_cognitiveservices_customvisiontraining (~> 0.17.2) + azure_cognitiveservices_entitysearch (~> 0.18.1) + azure_cognitiveservices_face (~> 0.19.0) + azure_cognitiveservices_formrecognizer (~> 0.17.1) + azure_cognitiveservices_imagesearch (~> 0.18.1) + azure_cognitiveservices_localsearch (~> 0.17.1) + azure_cognitiveservices_luisauthoring (~> 0.17.3) + azure_cognitiveservices_luisruntime (~> 0.17.2) + azure_cognitiveservices_newssearch (~> 0.18.1) + azure_cognitiveservices_personalizer (~> 0.17.0) + azure_cognitiveservices_qnamaker (~> 0.17.2) + azure_cognitiveservices_qnamakerruntime (~> 0.17.0) + azure_cognitiveservices_spellcheck (~> 0.18.1) + azure_cognitiveservices_textanalytics (~> 0.17.3) + azure_cognitiveservices_videosearch (~> 0.18.1) + azure_cognitiveservices_visualsearch (~> 0.18.1) + azure_cognitiveservices_websearch (~> 0.18.1) + azure_event_grid (~> 0.18.0) + azure_graph_rbac (~> 0.17.1) + azure_key_vault (~> 0.17.3) + azure_mgmt_adhybridhealth_service (~> 0.17.0) + azure_mgmt_advisor (~> 0.17.0) + azure_mgmt_alerts_management (~> 0.17.0) + azure_mgmt_analysis_services (~> 0.17.2) + azure_mgmt_api_management (~> 0.18.4) + azure_mgmt_appconfiguration (~> 0.17.0) + azure_mgmt_attestation (~> 0.17.0) + azure_mgmt_authorization (~> 0.18.4) + azure_mgmt_automation (~> 0.17.2) + azure_mgmt_azurestack (~> 0.17.0) + azure_mgmt_batch (~> 0.17.2) + azure_mgmt_batchai (~> 0.17.0) + azure_mgmt_billing (~> 0.17.2) + azure_mgmt_bot_service (~> 0.17.0) + azure_mgmt_cdn (~> 0.17.3) + azure_mgmt_cognitive_services (~> 0.18.3) + azure_mgmt_commerce (~> 0.17.1) + azure_mgmt_compute (~> 0.18.7) + azure_mgmt_consumption (~> 0.17.2) + azure_mgmt_container_instance (~> 0.17.4) + azure_mgmt_container_registry (~> 0.18.3) + azure_mgmt_container_service (~> 0.19.0) + azure_mgmt_cosmosdb (~> 0.18.0) + azure_mgmt_cost_management (~> 0.17.0) + azure_mgmt_customer_insights (~> 0.17.2) + azure_mgmt_data_factory (~> 0.17.0) + azure_mgmt_data_migration (~> 0.17.3) + azure_mgmt_databox (~> 0.17.0) + azure_mgmt_datalake_analytics (~> 0.17.2) + azure_mgmt_datalake_store (~> 0.17.2) + azure_mgmt_datashare (~> 0.17.0) + azure_mgmt_dev_spaces (~> 0.17.2) + azure_mgmt_devtestlabs (~> 0.18.0) + azure_mgmt_dns (~> 0.17.4) + azure_mgmt_edgegateway (~> 0.18.0) + azure_mgmt_event_grid (~> 0.17.6) + azure_mgmt_event_hub (~> 0.17.3) + azure_mgmt_features (~> 0.17.2) + azure_mgmt_hanaonazure (~> 0.17.1) + azure_mgmt_hdinsight (~> 0.17.5) + azure_mgmt_iot_central (~> 0.17.3) + azure_mgmt_iot_hub (~> 0.17.3) + azure_mgmt_key_vault (~> 0.17.4) + azure_mgmt_kusto (~> 0.18.0) + azure_mgmt_labservices (~> 0.17.1) + azure_mgmt_links (~> 0.17.2) + azure_mgmt_locks (~> 0.17.3) + azure_mgmt_logic (~> 0.18.1) + azure_mgmt_machine_learning (~> 0.17.2) + azure_mgmt_machine_learning_services (~> 0.17.2) + azure_mgmt_managed_applications (~> 0.17.2) + azure_mgmt_mariadb (~> 0.17.1) + azure_mgmt_marketplace_ordering (~> 0.17.4) + azure_mgmt_media_services (~> 0.19.0) + azure_mgmt_migrate (~> 0.17.0) + azure_mgmt_mixedreality (~> 0.17.2) + azure_mgmt_monitor (~> 0.17.4) + azure_mgmt_msi (~> 0.17.1) + azure_mgmt_mysql (~> 0.17.0) + azure_mgmt_netapp (~> 0.18.1) + azure_mgmt_network (~> 0.21.0) + azure_mgmt_notification_hubs (~> 0.17.2) + azure_mgmt_operational_insights (~> 0.17.2) + azure_mgmt_operations_management (~> 0.17.0) + azure_mgmt_peering (~> 0.17.0) + azure_mgmt_policy (~> 0.17.6) + azure_mgmt_policy_insights (~> 0.17.4) + azure_mgmt_postgresql (~> 0.17.1) + azure_mgmt_powerbi_dedicated (~> 0.17.0) + azure_mgmt_powerbi_embedded (~> 0.17.1) + azure_mgmt_privatedns (~> 0.17.1) + azure_mgmt_recovery_services (~> 0.17.3) + azure_mgmt_recovery_services_backup (~> 0.17.2) + azure_mgmt_recovery_services_site_recovery (~> 0.17.2) + azure_mgmt_redis (~> 0.17.3) + azure_mgmt_relay (~> 0.17.2) + azure_mgmt_reservations (~> 0.18.0) + azure_mgmt_resourcegraph (~> 0.17.1) + azure_mgmt_resources (~> 0.17.7) + azure_mgmt_resources_management (~> 0.17.1) + azure_mgmt_scheduler (~> 0.17.1) + azure_mgmt_search (~> 0.17.2) + azure_mgmt_security (~> 0.18.0) + azure_mgmt_serialconsole (~> 0.17.0) + azure_mgmt_service_bus (~> 0.17.3) + azure_mgmt_service_fabric (~> 0.17.2) + azure_mgmt_signalr (~> 0.17.4) + azure_mgmt_sql (~> 0.18.0) + azure_mgmt_sqlvirtualmachine (~> 0.18.0) + azure_mgmt_stor_simple8000_series (~> 0.17.2) + azure_mgmt_storage (~> 0.18.1) + azure_mgmt_storagecache (~> 0.17.0) + azure_mgmt_storagesync (~> 0.17.0) + azure_mgmt_stream_analytics (~> 0.17.2) + azure_mgmt_subscriptions (~> 0.18.1) + azure_mgmt_traffic_manager (~> 0.17.2) + azure_mgmt_vmware_cloudsimple (~> 0.17.0) + azure_mgmt_web (~> 0.17.4) + azure_service_fabric (~> 0.17.2) + azure_service_fabric (0.17.2) + ms_rest_azure (~> 0.11.1) backports (3.15.0) berkshelf (7.0.8) chef (>= 13.6.52) @@ -62,11 +452,11 @@ GEM solve (~> 4.0) thor (>= 0.20) builder (3.2.3) - c21e (1.1.9) - chef (14.12.9) + c21e (2.0.0) + chef (14.14.25) addressable bundler (>= 1.10) - chef-config (= 14.12.9) + chef-config (= 14.14.25) chef-zero (>= 13.0) diff-lcs (~> 1.2, >= 1.2.4) erubis (~> 2.7) @@ -93,7 +483,7 @@ GEM specinfra (~> 2.10) syslog-logger (~> 1.6) uuidtools (~> 2.1.5) - chef-config (14.12.9) + chef-config (14.14.25) addressable fuzzyurl mixlib-config (>= 2.2.12, < 4.0) @@ -123,7 +513,7 @@ GEM winrm-fs (~> 1.0) chef-sugar (5.0.1) chef-vault (3.3.0) - chef-zero (14.0.12) + chef-zero (14.0.13) ffi-yajl (~> 2.2) hashie (>= 2.0, < 4.0) mixlib-log (>= 2.0, < 4.0) @@ -138,25 +528,32 @@ GEM color (1.8) colorize (0.8.1) concurrent-ruby (1.1.5) - cookbook-omnifetch (0.8.1) + cookbook-omnifetch (0.9.0) mixlib-archive (>= 0.4, < 2.0) - cucumber-core (4.0.0) - backports (>= 3.8.0) - cucumber-tag_expressions (~> 1.1.0) - gherkin (~> 6.0) - cucumber-messages (2.1.2) - google-protobuf (>= 3.2, <= 3.7) - cucumber-tag_expressions (1.1.1) + cucumber-core (5.0.1) + backports (~> 3.15, >= 3.15.0) + cucumber-tag_expressions (~> 2.0, >= 2.0.2) + gherkin (~> 7.0, >= 7.0.3) + cucumber-messages (5.0.1) + google-protobuf (>= 3.2, <= 3.8) + cucumber-tag_expressions (2.0.2) daemons (1.3.1) declarative (0.0.10) declarative-option (0.1.0) diff-lcs (1.3) + domain_name (0.5.20190701) + unf (>= 0.0.5, < 1.0.0) erubis (2.7.0) eventmachine (1.2.7) - faraday (0.15.4) + faraday (0.17.0) multipart-post (>= 1.2, < 3) + faraday-cookie_jar (0.0.6) + faraday (>= 0.7.4) + http-cookie (~> 1.0.0) + faraday_middleware (0.13.1) + faraday (>= 0.7.4, < 1.0) ffi (1.11.1) - ffi-libarchive (0.4.6) + ffi-libarchive (0.4.10) ffi (~> 1.0) ffi-yajl (2.3.1) libyajl2 (~> 1.2) @@ -169,18 +566,18 @@ GEM rufus-lru (~> 1.0) treetop (~> 1.4) fuzzyurl (0.9.0) - gherkin (6.0.17) - c21e (~> 1.1.9) - cucumber-messages (~> 2.1.2) - google-api-client (0.28.7) + gherkin (7.0.4) + c21e (~> 2.0, >= 2.0.0) + cucumber-messages (~> 5.0, >= 5.0.1) + google-api-client (0.30.10) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.5, < 0.10.0) httpclient (>= 2.8.1, < 3.0) - mime-types (~> 3.0) + mini_mime (~> 1.0) representable (~> 3.0) retriable (>= 2.0, < 4.0) signet (~> 0.10) - google-protobuf (3.7.0) + google-protobuf (3.8.0) googleauth (0.9.0) faraday (~> 0.12) jwt (>= 1.4, < 3.0) @@ -194,6 +591,8 @@ GEM builder (>= 2.1.2) hashie (3.6.0) highline (1.7.10) + http-cookie (1.0.3) + domain_name (~> 0.5) httpclient (2.8.3) inifile (3.0.0) iniparse (1.4.4) @@ -209,18 +608,16 @@ GEM little-plugger (~> 1.1) multi_json (~> 1.10) memoist (0.16.0) - mime-types (3.2.2) - mime-types-data (~> 3.2015) - mime-types-data (3.2019.0331) + mini_mime (1.0.2) mini_portile2 (2.4.0) - minitar (0.8) + minitar (0.9) mixlib-archive (1.0.1) mixlib-log mixlib-authentication (2.1.1) mixlib-cli (1.7.0) mixlib-config (3.0.1) tomlrb - mixlib-install (3.11.18) + mixlib-install (3.11.21) mixlib-shellout mixlib-versioning thor @@ -228,7 +625,17 @@ GEM mixlib-shellout (2.4.4) mixlib-versioning (1.2.7) molinillo (0.6.6) - multi_json (1.13.1) + ms_rest (0.7.4) + concurrent-ruby (~> 1.0) + faraday (~> 0.9) + timeliness (~> 0.3.10) + ms_rest_azure (0.11.1) + concurrent-ruby (~> 1.0) + faraday (~> 0.9) + faraday-cookie_jar (~> 0.0.6) + ms_rest (~> 0.7.4) + unf_ext (= 0.0.7.2) + multi_json (1.14.1) multipart-post (2.1.1) mysql2 (0.5.2) net-ldap (0.16.1) @@ -243,14 +650,14 @@ GEM net-ssh (>= 2.6.5) net-ssh-gateway (>= 1.2.0) net-telnet (0.1.1) - netaddr (2.0.3) + netaddr (2.0.4) nokogiri (1.10.4) mini_portile2 (~> 2.4.0) nori (2.6.0) numerizer (0.1.1) octokit (4.14.0) sawyer (~> 0.8.0, >= 0.5.3) - ohai (14.8.12) + ohai (14.14.0) chef-config (>= 12.8, < 15) ffi (~> 1.9) ffi-yajl (~> 2.2) @@ -265,43 +672,42 @@ GEM optimist (3.0.0) os (1.0.1) paint (1.0.1) - parallel (1.17.0) - parser (2.6.3.0) + parallel (1.18.0) + parser (2.6.5.0) ast (~> 2.4.0) - pg (0.18.4) plist (3.5.0) polyglot (0.3.5) proxifier (1.0.3) - public_suffix (3.0.3) + public_suffix (3.1.1) rack (2.0.7) rainbow (3.0.0) - rake (12.3.2) + rake (13.0.0) representable (3.0.4) declarative (< 0.1.0) declarative-option (< 0.2.0) uber (< 0.2.0) retriable (3.1.2) retryable (3.0.4) - rspec (3.8.0) - rspec-core (~> 3.8.0) - rspec-expectations (~> 3.8.0) - rspec-mocks (~> 3.8.0) - rspec-core (3.8.0) - rspec-support (~> 3.8.0) - rspec-expectations (3.8.3) + rspec (3.9.0) + rspec-core (~> 3.9.0) + rspec-expectations (~> 3.9.0) + rspec-mocks (~> 3.9.0) + rspec-core (3.9.0) + rspec-support (~> 3.9.0) + rspec-expectations (3.9.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) + rspec-support (~> 3.9.0) rspec-its (1.3.0) rspec-core (>= 3.0.0) rspec-expectations (>= 3.0.0) - rspec-mocks (3.8.0) + rspec-mocks (3.9.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-support (3.8.0) + rspec-support (~> 3.9.0) + rspec-support (3.9.0) rspec_junit_formatter (0.2.3) builder (< 4) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (0.74.0) + rubocop (0.76.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) parser (>= 2.6) @@ -312,19 +718,19 @@ GEM ruby-progressbar (1.10.1) ruby-wmi (0.4.0) rubyntlm (0.6.2) - rubyzip (1.2.3) + rubyzip (1.3.0) rufus-lru (1.1.0) sawyer (0.8.2) addressable (>= 2.3.5) faraday (> 0.8, < 2.0) semverse (3.0.0) - serverspec (2.41.4) + serverspec (2.41.5) multi_json rspec (~> 3.0) rspec-its specinfra (~> 2.72) sfl (2.3) - signet (0.11.0) + signet (0.12.0) addressable (~> 2.3) faraday (~> 0.9) jwt (>= 1.5, < 3.0) @@ -334,7 +740,7 @@ GEM solve (4.0.2) molinillo (~> 0.6) semverse (>= 1.1, < 4.0) - specinfra (2.77.2) + specinfra (2.82.3) net-scp net-ssh (>= 2.7) net-telnet (= 0.1.1) @@ -346,10 +752,14 @@ GEM eventmachine (~> 1.0, >= 1.0.4) rack (>= 1, < 3) thor (0.20.3) + timeliness (0.3.10) tomlrb (1.2.8) treetop (1.6.10) polyglot (~> 0.3) uber (0.1.0) + unf (0.1.4) + unf_ext + unf_ext (0.0.7.2) unicode-display_width (1.6.0) uuidtools (2.1.5) winrm (2.3.2) @@ -376,7 +786,7 @@ PLATFORMS ruby DEPENDENCIES - berkshelf (~> 7.0.6) + berkshelf (~> 7.0) chef-dk (~> 3.2.30) chef-sugar chef-vault (~> 3.3.0) @@ -385,11 +795,10 @@ DEPENDENCIES foodcritic (~> 14.1.0) knife-windows! mysql2 - pg (~> 0.18.4) rack ruby-wmi thin winrm (~> 2.3, >= 2.3.2) BUNDLED WITH - 1.17.2 + 1.17.3 diff --git a/modules/mommacat.ru b/modules/mommacat.ru index 524f6d4fd..55cedb054 100644 --- a/modules/mommacat.ru +++ b/modules/mommacat.ru @@ -32,7 +32,12 @@ $LOAD_PATH << "#{$MUDIR}/modules" require File.realpath(File.expand_path(File.dirname(__FILE__)+"/mu-load-config.rb")) require 'mu' -MU::Groomer::Chef.loadChefLib # pre-cache this so we don't take a hit on a user-interactive need +begin + MU::Groomer::Chef.loadChefLib # pre-cache this so we don't take a hit on a user-interactive need + $ENABLE_SCRATCHPAD = true +rescue LoadError + MU.log "Chef libraries not available, disabling Scratchpad", MU::WARN +end #MU.setLogging($opts[:verbose], $opts[:web]) if MU.myCloud == "AWS" MU::Cloud::AWS.openFirewallForClients # XXX add the other clouds, or abstract @@ -41,7 +46,7 @@ end Signal.trap("URG") do puts "------------------------------" puts "Open flock() locks:" - pp MU::MommaCat.locks + pp MU::MommaCat.trapSafeLocks puts "------------------------------" end @@ -57,7 +62,7 @@ Thread.new { MU.dupGlobals(parent_thread_id) begin MU::MommaCat.cleanTerminatedInstances - MU::Master.cleanExpiredScratchpads + MU::Master.cleanExpiredScratchpads if $ENABLE_SCRATCHPAD sleep 60 rescue Exception => e MU.log "Error in cleanTerminatedInstances thread: #{e.inspect}", MU::ERR, details: e.backtrace @@ -199,6 +204,17 @@ app = proc do |env| ] begin if !env.nil? and !env['REQUEST_PATH'].nil? and env['REQUEST_PATH'].match(/^\/scratchpad/) + if !$ENABLE_SCRATCHPAD + msg = "Scratchpad disabled in non-Chef Mu installations" + return [ + 504, + { + 'Content-Type' => 'text/html', + 'Content-Length' => msg.length.to_s + }, + [msg] + ] + end itemname = env['REQUEST_PATH'].sub(/^\/scratchpad\//, "") begin if itemname.sub!(/\/secret$/, "") @@ -257,7 +273,7 @@ app = proc do |env| [page] ] end - rescue MU::Groomer::Chef::MuNoSuchSecret + rescue MU::Groomer::MuNoSuchSecret page = nil if $MU_CFG.has_key?('scratchpad') and $MU_CFG['scratchpad'].has_key?("template_path") and @@ -287,6 +303,7 @@ app = proc do |env| ] end elsif !env.nil? and !env['REQUEST_PATH'].nil? and env['REQUEST_PATH'].match(/^\/rest\//) + action, filter, path = env['REQUEST_PATH'].sub(/^\/rest\/?/, "").split(/\//, 3) # Don't give away the store. This can't be public until we can # authenticate and access-control properly. @@ -295,7 +312,23 @@ app = proc do |env| next end - if action == "deploy" + if action == "hosts_add" + if Process.uid != 0 + returnval = throw500 "Service not available" + elsif !filter or !path + returnval = throw404 env['REQUEST_PATH'] + else + MU::MommaCat.addInstanceToEtcHosts(path, filter) + returnval = [ + 200, + { + 'Content-Type' => 'text/plain', + 'Content-Length' => 2 + }, + ["ok"] + ] + end + elsif action == "deploy" returnval = throw404 env['REQUEST_PATH'] if !filter MU.log "Loading deploy data for #{filter} #{path}" kittenpile = MU::MommaCat.getLitter(filter) @@ -313,9 +346,9 @@ app = proc do |env| 200, { 'Content-Type' => 'text/plain', - 'Content-Length' => MU.adminBucketName.length.to_s + 'Content-Length' => MU.adminBucketName(filter, credentials: path).length.to_s }, - [MU.adminBucketName] + [MU.adminBucketName(filter, credentials: path)] ] else returnval = throw404 env['REQUEST_PATH'] @@ -389,7 +422,8 @@ app = proc do |env| if instance.respond_to?(:addVolume) # XXX make sure we handle mangled input safely params = JSON.parse(Base64.decode64(req["add_volume"])) - instance.addVolume(params["dev"], params["size"]) +MU.log "ADDVOLUME REQUEST", MU::WARN, details: params + instance.addVolume(params["dev"], params["size"], delete_on_termination: params["delete_on_termination"]) else returnval = throw500 "I don't know how to add a volume for #{instance}" ok = false diff --git a/modules/mu.rb b/modules/mu.rb index 8550d3dbb..80019e995 100644 --- a/modules/mu.rb +++ b/modules/mu.rb @@ -36,20 +36,252 @@ class << self; end end +# Mu extensions to Ruby's {Hash} type for internal Mu use +class Hash + + # Strip extraneous fields out of a {MU::Config} hash to make it suitable for + # shorthand printing, such as with mu-adopt --diff + def self.bok_minimize(o) + if o.is_a?(Hash) + newhash = o.reject { |k, v| + !v.is_a?(Array) and !v.is_a?(Hash) and !["name", "id", "cloud_id"].include?(k) + } +# newhash.delete("cloud_id") if newhash["name"] or newhash["id"] + newhash.each_pair { |k, v| + newhash[k] = bok_minimize(v) + } + newhash.reject! { |_k, v| v.nil? or v.empty? } + newhash = newhash.values.first if newhash.size == 1 + return newhash + elsif o.is_a?(Array) + newarray = [] + o.each { |v| + newvalue = bok_minimize(v) + newarray << newvalue if !newvalue.nil? and !newvalue.empty? + } + newarray = newarray.first if newarray.size == 1 + return newarray + end + + o + end + + # A comparison function for sorting arrays of hashes + def <=>(other) + return 1 if other.nil? or self.size > other.size + return -1 if other.size > self.size + # Sort any array children we have + self.each_pair { |k, v| + self[k] = v.sort if v.is_a?(Array) + } + other.each_pair { |k, v| + other[k] = v.sort if v.is_a?(Array) + } + return 0 if self == other # that was easy! + # compare elements and decide who's "bigger" based on their totals? + 0 + end + + # Recursively compare two hashes + def diff(with, on = self, level: 0, parents: []) + return if with.nil? and on.nil? + if with.nil? or on.nil? or with.class != on.class + return # XXX ...however we're flagging differences + end + return if on == with + + tree = "" + indentsize = 0 + parents.each { |p| + tree += (" " * indentsize) + p + " => \n" + indentsize += 2 + } + indent = (" " * indentsize) + + changes = [] + if on.is_a?(Hash) + on_unique = (on.keys - with.keys) + with_unique = (with.keys - on.keys) + shared = (with.keys & on.keys) + shared.each { |k| + diff(with[k], on[k], level: level+1, parents: parents + [k]) + } + on_unique.each { |k| + changes << "- ".red+PP.pp({k => on[k] }, '') + } + with_unique.each { |k| + changes << "+ ".green+PP.pp({k => with[k]}, '') + } + elsif on.is_a?(Array) + return if with == on + # special case- Basket of Kittens lists of declared resources of a type; + # we use this to decide if we can compare two array elements as if they + # should be equivalent + # We also implement comparison operators for {Hash} and our various + # custom objects which we might find in here so that we can get away with + # sorting arrays full of weird, non-primitive types. + done = [] +# before_a = on.dup +# after_a = on.dup.sort +# before_b = with.dup +# after_b = with.dup.sort + on.sort.each { |elt| + if elt.is_a?(Hash) and elt['name'] or elt['entity']# or elt['cloud_id'] + with.sort.each { |other_elt| + if (elt['name'] and other_elt['name'] == elt['name']) or + (elt['name'].nil? and !elt["id"].nil? and elt["id"] == other_elt["id"]) or + (elt['name'].nil? and elt["id"].nil? and + !elt["entity"].nil? and !other_elt["entity"].nil? and + ( + (elt["entity"]["id"] and elt["entity"]["id"] == other_elt["entity"]["id"]) or + (elt["entity"]["name"] and elt["entity"]["name"] == other_elt["entity"]["name"]) + ) + ) + break if elt == other_elt + done << elt + done << other_elt + namestr = if elt['type'] + "#{elt['type']}[#{elt['name']}]" + elsif elt['name'] + elt['name'] + elsif elt['entity'] and elt["entity"]["id"] + elt['entity']['id'] + end + + diff(other_elt, elt, level: level+1, parents: parents + [namestr]) + break + end + } + end + } + on_unique = (on - with) - done + with_unique = (with - on) - done +# if on_unique.size > 0 or with_unique.size > 0 +# if before_a != after_a +# MU.log "A BEFORE", MU::NOTICE, details: before_a +# MU.log "A AFTER", MU::NOTICE, details: after_a +# end +# if before_b != after_b +# MU.log "B BEFORE", MU::NOTICE, details: before_b +# MU.log "B AFTER", MU::NOTICE, details: after_b +# end +# end + on_unique.each { |e| + changes << if e.is_a?(Hash) + "- ".red+PP.pp(Hash.bok_minimize(e), '').gsub(/\n/, "\n "+(indent)) + else + "- ".red+e.to_s + end + } + with_unique.each { |e| + changes << if e.is_a?(Hash) + "+ ".green+PP.pp(Hash.bok_minimize(e), '').gsub(/\n/, "\n "+(indent)) + else + "+ ".green+e.to_s + end + } + else + if on != with + changes << "-".red+" #{on.to_s}" + changes << "+".green+" #{with.to_s}" + end + end + + if changes.size > 0 + puts tree + changes.each { |c| + puts indent+c + } + end + end + + # Implement a merge! that just updates each hash leaf as needed, not + # trashing the branch on the way there. + def deep_merge!(with, on = self) + + if on and with and with.is_a?(Hash) + with.each_pair { |k, v| + if !on[k] or !on[k].is_a?(Hash) + on[k] = v + else + deep_merge!(with[k], on[k]) + end + } + elsif with + on = with + end + + on + end +end + ENV['HOME'] = Etc.getpwuid(Process.uid).dir require 'mu/logger' module MU + # Subclass core thread so we can gracefully handle it when we hit system + # thread limits. Back off and wait makes sense for us, since most of our + # threads are terminal (in the dependency sense) and this is unlikely to get + # us deadlocks. + class Thread < ::Thread + @@mu_global_threads = [] + @@mu_global_thread_semaphore = Mutex.new + + def initialize(*args, &block) + @@mu_global_thread_semaphore.synchronize { + @@mu_global_threads.reject! { |t| t.nil? or !t.status } + } + newguy = nil + start = Time.now + begin + newguy = super(*args, &block) + if newguy.nil? + MU.log "I somehow got a nil trying to create a thread", MU::WARN, details: caller + sleep 1 + end + rescue ::ThreadError => e + if e.message.match(/Resource temporarily unavailable/) + toomany = @@mu_global_threads.size + MU.log "Hit the wall at #{toomany.to_s} threads, waiting until there are fewer", MU::WARN + if @@mu_global_threads.size >= toomany + sleep 1 + begin + @@mu_global_thread_semaphore.synchronize { + @@mu_global_threads.each { |t| + next if t == ::Thread.current + t.join(0.1) + } + @@mu_global_threads.reject! { |t| t.nil? or !t.status } + } + if (Time.now - start) > 150 + MU.log "Failed to get a free thread slot after 150 seconds- are we in a deadlock situation?", MU::ERR, details: caller + raise e + end + end while @@mu_global_threads.size >= toomany + end + retry + else + raise e + end + end while newguy.nil? + + @@mu_global_thread_semaphore.synchronize { + @@mu_global_threads << newguy + } + + end + end + # Wrapper class for fatal Exceptions. Gives our internals something to # inherit that will log an error message appropriately before bubbling up. class MuError < StandardError def initialize(message = nil) - MU.log message, MU::ERR if !message.nil? + MU.log message, MU::ERR, details: caller[2] if !message.nil? if MU.verbosity == MU::Logger::SILENT - super - else super "" + else + super message end end end @@ -60,9 +292,9 @@ class MuNonFatal < StandardError def initialize(message = nil) MU.log message, MU::NOTICE if !message.nil? if MU.verbosity == MU::Logger::SILENT - super - else super "" + else + super message end end end @@ -80,11 +312,37 @@ def self.myRoot; @@myRoot end + # utility routine for sorting semantic versioning strings + def self.version_sort(a, b) + a_parts = a.split(/[^a-z0-9]/) + b_parts = b.split(/[^a-z0-9]/) + for i in 0..a_parts.size + matchval = if a_parts[i] and b_parts[i] and + a_parts[i].match(/^\d+/) and b_parts[i].match(/^\d+/) + a_parts[i].to_i <=> b_parts[i].to_i + elsif a_parts[i] and !b_parts[i] + 1 + elsif !a_parts[i] and b_parts[i] + -1 + else + a_parts[i] <=> b_parts[i] + end + return matchval if matchval != 0 + end + 0 + end + # Front our global $MU_CFG hash with a read-only copy def self.muCfg Marshal.load(Marshal.dump($MU_CFG)).freeze end + # Returns true if we're running without a full systemwide Mu Master install, + # typically as a gem. + def self.localOnly + ((Gem.paths and Gem.paths.home and File.realpath(File.expand_path(File.dirname(__FILE__))).match(/^#{Gem.paths.home}/)) or !Dir.exist?("/opt/mu")) + end + # The main (root) Mu user's data directory. @@mainDataDir = File.expand_path(@@myRoot+"/../var") # The main (root) Mu user's data directory. @@ -133,6 +391,7 @@ def self.setVar(name, value) # Copy the set of global variables in use by another thread, typically our # parent thread. def self.dupGlobals(parent_thread_id) + @@globals[parent_thread_id] ||= {} @@globals[parent_thread_id].each_pair { |name, value| setVar(name, value) } @@ -145,41 +404,49 @@ def self.purgeGlobals # Accessor for per-thread global variable. There is probably a Ruby-clever way to define this. def self.mommacat; + @@globals[Thread.current.object_id] ||= {} @@globals[Thread.current.object_id]['mommacat'] end # Accessor for per-thread global variable. There is probably a Ruby-clever way to define this. def self.deploy_id; + @@globals[Thread.current.object_id] ||= {} @@globals[Thread.current.object_id]['deploy_id'] end # Accessor for per-thread global variable. There is probably a Ruby-clever way to define this. def self.appname; + @@globals[Thread.current.object_id] ||= {} @@globals[Thread.current.object_id]['appname'] end # Accessor for per-thread global variable. There is probably a Ruby-clever way to define this. def self.environment; + @@globals[Thread.current.object_id] ||= {} @@globals[Thread.current.object_id]['environment'] end # Accessor for per-thread global variable. There is probably a Ruby-clever way to define this. def self.timestamp; + @@globals[Thread.current.object_id] ||= {} @@globals[Thread.current.object_id]['timestamp'] end # Accessor for per-thread global variable. There is probably a Ruby-clever way to define this. def self.seed; + @@globals[Thread.current.object_id] ||= {} @@globals[Thread.current.object_id]['seed'] end # Accessor for per-thread global variable. There is probably a Ruby-clever way to define this. def self.handle; + @@globals[Thread.current.object_id] ||= {} @@globals[Thread.current.object_id]['handle'] end # Accessor for per-thread global variable. There is probably a Ruby-clever way to define this. def self.chef_user; + @@globals[Thread.current.object_id] ||= {} if @@globals.has_key?(Thread.current.object_id) and @@globals[Thread.current.object_id].has_key?('chef_user') @@globals[Thread.current.object_id]['chef_user'] elsif Etc.getpwuid(Process.uid).name == "root" @@ -191,6 +458,7 @@ def self.chef_user; # Accessor for per-thread global variable. There is probably a Ruby-clever way to define this. def self.mu_user + @@globals[Thread.current.object_id] ||= {} if @@globals.has_key?(Thread.current.object_id) and @@globals[Thread.current.object_id].has_key?('mu_user') return @@globals[Thread.current.object_id]['mu_user'] elsif Etc.getpwuid(Process.uid).name == "root" @@ -202,11 +470,13 @@ def self.mu_user # Accessor for per-thread global variable. There is probably a Ruby-clever way to define this. def self.curRegion + @@globals[Thread.current.object_id] ||= {} @@globals[Thread.current.object_id]['curRegion'] ||= myRegion || ENV['EC2_REGION'] end # Accessor for per-thread global variable. There is probably a Ruby-clever way to define this. def self.syncLitterThread; + @@globals[Thread.current.object_id] ||= {} @@globals[Thread.current.object_id]['syncLitterThread'] end @@ -214,32 +484,32 @@ def self.syncLitterThread; @myDataDir = File.expand_path(ENV['MU_DATADIR']) if ENV.has_key?("MU_DATADIR") @myDataDir = @@mainDataDir if @myDataDir.nil? # Mu's deployment metadata directory. - def self.dataDir - if MU.mu_user.nil? or MU.mu_user.empty? or MU.mu_user == "mu" or MU.mu_user == "root" + def self.dataDir(for_user = MU.mu_user) + if !localOnly and + ((Process.uid == 0 and (for_user.nil? or for_user.empty?)) or + for_user == "mu" or for_user == "root") return @myDataDir else - basepath = Etc.getpwnam(MU.mu_user).dir+"/.mu" - Dir.mkdir(basepath, 0755) if !Dir.exists?(basepath) - Dir.mkdir(basepath+"/var", 0755) if !Dir.exists?(basepath+"/var") + for_user ||= MU.mu_user + basepath = Etc.getpwnam(for_user).dir+"/.mu" + Dir.mkdir(basepath, 0755) if !Dir.exist?(basepath) + Dir.mkdir(basepath+"/var", 0755) if !Dir.exist?(basepath+"/var") return basepath+"/var" end end - # The verbose logging flag merits a default value. + # Return the verbosity setting of the default @@logger object def self.verbosity - if @@globals[Thread.current.object_id].nil? or @@globals[Thread.current.object_id]['verbosity'].nil? - MU.setVar("verbosity", MU::Logger::NORMAL) - end - @@globals[Thread.current.object_id]['verbosity'] + @@logger ? @@logger.verbosity : MU::Logger::NORMAL end # Set parameters parameters for calls to {MU#log} - def self.setLogging(verbosity, webify_logs = false, handle = STDOUT) - MU.setVar("verbosity", verbosity) - @@logger ||= MU::Logger.new(verbosity, webify_logs, handle) + def self.setLogging(verbosity, webify_logs = false, handle = STDOUT, color = true) + @@logger ||= MU::Logger.new(verbosity, webify_logs, handle, color) @@logger.html = webify_logs @@logger.verbosity = verbosity @@logger.handle = handle + @@logger.color = color end setLogging(MU::Logger::NORMAL, false) @@ -251,33 +521,25 @@ def self.summary end # Shortcut to invoke {MU::Logger#log} - def self.log(msg, level = MU::INFO, details: nil, html: html = false, verbosity: MU.verbosity) - return if (level == MU::DEBUG and verbosity <= MU::Logger::LOUD) - return if verbosity == MU::Logger::SILENT + def self.log(msg, level = MU::INFO, details: nil, html: false, verbosity: nil, color: true) + return if (level == MU::DEBUG and verbosity and verbosity <= MU::Logger::LOUD) + return if verbosity and verbosity == MU::Logger::SILENT if (level == MU::ERR or level == MU::WARN or level == MU::DEBUG or - verbosity >= MU::Logger::LOUD or - (level == MU::NOTICE and !details.nil?) - ) - # TODO add more stuff to details here (e.g. call stack) - extra = nil - if Thread.current.thread_variable_get("name") and (level > MU::NOTICE or verbosity >= MU::Logger::LOUD) - extra = Hash.new - extra = { - :thread => Thread.current.object_id, - :name => Thread.current.thread_variable_get("name") - } - end - if !details.nil? - extra = Hash.new if extra.nil? - extra[:details] = details - end - @@logger.log(msg, level, details: extra, verbosity: MU::Logger::LOUD, html: html) - else - @@logger.log(msg, level, html: html, verbosity: verbosity) + (verbosity and verbosity >= MU::Logger::LOUD) or + (level == MU::NOTICE and !details.nil?)) and + Thread.current.thread_variable_get("name") + newdetails = { + :thread => Thread.current.object_id, + :name => Thread.current.thread_variable_get("name") + } + newdetails[:details] = details.dup if details + details = newdetails end + + @@logger.log(msg, level, details: details, html: html, verbosity: verbosity, color: color) end # For log entries that should only be logged when we're in verbose mode @@ -307,49 +569,66 @@ def self.log(msg, level = MU::INFO, details: nil, html: html = false, verbosity: require 'mu/groomer' # Little hack to initialize library-only environments' config files - if !$MU_CFG - require "#{@@myRoot}/bin/mu-load-config.rb" - - if !$MU_CFG['auto_detection_done'] and (!$MU_CFG['multiuser'] or !cfgExists?) - MU.log "Auto-detecting cloud providers" - new_cfg = $MU_CFG.dup - examples = {} - MU::Cloud.supportedClouds.each { |cloud| - cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud) - begin - if cloudclass.hosted? and !$MU_CFG[cloud.downcase] - cfg_blob = cloudclass.hosted_config - if cfg_blob - new_cfg[cloud.downcase] = cfg_blob - MU.log "Adding #{cloud} stanza to #{cfgPath}", MU::NOTICE - end - elsif !$MU_CFG[cloud.downcase] and !cloudclass.config_example.nil? - examples[cloud.downcase] = cloudclass.config_example + def self.detectCloudProviders + MU.log "Auto-detecting cloud providers" + new_cfg = $MU_CFG.dup + examples = {} + MU::Cloud.supportedClouds.each { |cloud| + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud) + begin + if cloudclass.hosted? and !$MU_CFG[cloud.downcase] + cfg_blob = cloudclass.hosted_config + if cfg_blob + new_cfg[cloud.downcase] = cfg_blob + MU.log "Adding auto-detected #{cloud} stanza", MU::NOTICE end - rescue NoMethodError => e - # missing .hosted? is normal for dummy layers like CloudFormation - MU.log e.message, MU::WARN + elsif !$MU_CFG[cloud.downcase] and !cloudclass.config_example.nil? + examples[cloud.downcase] = cloudclass.config_example end - } - new_cfg['auto_detection_done'] = true - if new_cfg != $MU_CFG or !cfgExists? - MU.log "Generating #{cfgPath}" - saveMuConfig(new_cfg, examples) # XXX and reload it + rescue NoMethodError => e + # missing .hosted? is normal for dummy layers like CloudFormation + MU.log e.message, MU::WARN end + } + new_cfg['auto_detection_done'] = true + if new_cfg != $MU_CFG or !cfgExists? + MU.log "Generating #{cfgPath}" + saveMuConfig(new_cfg, examples) # XXX and reload it + end + new_cfg + end + + if !$MU_CFG + require "#{@@myRoot}/bin/mu-load-config.rb" + if !$MU_CFG['auto_detection_done'] and (!$MU_CFG['multiuser'] or !cfgExists?) + detectCloudProviders end end + @@mommacat_port = 2260 + if !$MU_CFG.nil? and !$MU_CFG['mommacat_port'].nil? and + !$MU_CFG['mommacat_port'] != "" and $MU_CFG['mommacat_port'].to_i > 0 and + $MU_CFG['mommacat_port'].to_i < 65536 + @@mommacat_port = $MU_CFG['mommacat_port'].to_i + end + # The port on which the Momma Cat daemon should listen for requests + # @return [Integer] + def self.mommaCatPort + @@mommacat_port + end + @@my_private_ip = nil @@my_public_ip = nil @@mu_public_addr = nil @@mu_public_ip = nil - if $MU_CFG['aws'] # XXX this should be abstracted to elsewhere + if MU::Cloud::AWS.hosted? @@my_private_ip = MU::Cloud::AWS.getAWSMetaData("local-ipv4") @@my_public_ip = MU::Cloud::AWS.getAWSMetaData("public-ipv4") @@mu_public_addr = @@my_public_ip @@mu_public_ip = @@my_public_ip end - if !$MU_CFG.nil? and !$MU_CFG['public_address'].nil? and !$MU_CFG['public_address'].empty? and @@my_public_ip != $MU_CFG['public_address'] + if !$MU_CFG.nil? and !$MU_CFG['public_address'].nil? and + !$MU_CFG['public_address'].empty? and @@my_public_ip != $MU_CFG['public_address'] @@mu_public_addr = $MU_CFG['public_address'] if !@@mu_public_addr.match(/^\d+\.\d+\.\d+\.\d+$/) resolver = Resolv::DNS.new @@ -399,7 +678,9 @@ def self.mu_public_addr; def self.userEmail(user = MU.mu_user) @userlist ||= MU::Master.listUsers user = "mu" if user == "root" - if Dir.exists?("#{MU.mainDataDir}/users/#{user}") + if Dir.exist?("#{MU.mainDataDir}/users/#{user}") and + File.readable?("#{MU.mainDataDir}/users/#{user}/email") and + File.size?("#{MU.mainDataDir}/users/#{user}/email") return File.read("#{MU.mainDataDir}/users/#{user}/email").chomp elsif @userlist.has_key?(user) return @userlist[user]['email'] @@ -412,7 +693,9 @@ def self.userEmail(user = MU.mu_user) # Fetch the real-world name of a given Mu user def self.userName(user = MU.mu_user) @userlist ||= MU::Master.listUsers - if Dir.exists?("#{MU.mainDataDir}/users/#{user}") + if Dir.exist?("#{MU.mainDataDir}/users/#{user}") and + File.readable?("#{MU.mainDataDir}/users/#{user}/realname") and + File.size?("#{MU.mainDataDir}/users/#{user}/realname") return File.read("#{MU.mainDataDir}/users/#{user}/realname").chomp elsif @userlist.has_key?(user) return @userlist[user]['email'] @@ -426,7 +709,15 @@ def self.userName(user = MU.mu_user) # XXX these guys to move into mu/groomer # List of known/supported grooming agents (configuration management tools) def self.supportedGroomers - ["Chef"] + ["Chef", "Ansible"] + end + + # The version of Chef we will install on nodes. + @@chefVersion = "14.0.190" + # The version of Chef we will install on nodes. + # @return [String] + def self.chefVersion + @@chefVersion end MU.supportedGroomers.each { |groomer| @@ -450,6 +741,8 @@ def self.myRegion @@myRegion_var = zone.gsub(/^.*?\/|\-\d+$/, "") elsif MU::Cloud::AWS.hosted? @@myRegion_var ||= MU::Cloud::AWS.myRegion + elsif MU::Cloud::Azure.hosted? + @@myRegion_var ||= MU::Cloud::Azure.myRegion else @@myRegion_var = nil end @@ -457,6 +750,7 @@ def self.myRegion end require 'mu/config' + require 'mu/adoption' # Figure out what cloud provider we're in, if any. # @return [String]: Google, AWS, etc. Returns nil if we don't seem to be in a cloud. @@ -467,6 +761,10 @@ def self.myCloud elsif MU::Cloud::AWS.hosted? @@myInstanceId = MU::Cloud::AWS.getAWSMetaData("instance-id") return "AWS" + elsif MU::Cloud::Azure.hosted? + metadata = MU::Cloud::Azure.get_metadata()["compute"] + @@myInstanceId = MU::Cloud::Azure::Id.new("/subscriptions/"+metadata["subscriptionId"]+"/resourceGroups/"+metadata["resourceGroupName"]+"/providers/Microsoft.Compute/virtualMachines/"+metadata["name"]) + return "Azure" end nil end @@ -512,63 +810,107 @@ def self.myAZ @@myAZ_var end - @@myCloudDescriptor = nil - if MU::Cloud::Google.hosted? - @@myCloudDescriptor = MU::Cloud::Google.compute.get_instance( - MU::Cloud::Google.myProject, - MU.myAZ, - MU.myInstanceId - ) - elsif MU::Cloud::AWS.hosted? + # Recursively turn a Ruby OpenStruct into a Hash + # @param struct [OpenStruct] + # @param stringify_keys [Boolean] + # @return [Hash] + def self.structToHash(struct, stringify_keys: false) + google_struct = false begin - @@myCloudDescriptor = MU::Cloud::AWS.ec2(region: MU.myRegion).describe_instances(instance_ids: [MU.myInstanceId]).reservations.first.instances.first - rescue Aws::EC2::Errors::InvalidInstanceIDNotFound => e - rescue Aws::Errors::MissingCredentialsError => e - MU.log "I'm hosted in AWS, but I can't make API calls. Does this instance have an appropriate IAM profile?", MU::WARN + google_struct = struct.class.ancestors.include?(::Google::Apis::Core::Hashable) + rescue NameError end - end + aws_struct = false + begin + aws_struct = struct.class.ancestors.include?(::Seahorse::Client::Response) + rescue NameError + end - @@myVPC_var = nil - # The VPC/Network in which this Mu master resides - # XXX account for Google and non-cloud situations - def self.myVPC - return nil if MU.myCloudDescriptor.nil? + azure_struct = false begin - if MU::Cloud::AWS.hosted? - @@myVPC_var ||= MU.myCloudDescriptor.vpc_id - elsif MU::Cloud::Google.hosted? - @@myVPC_var = MU.myCloudDescriptor.network_interfaces.first.network.gsub(/.*?\/([^\/]+)$/, '\1') + azure_struct = struct.class.ancestors.include?(::MsRestAzure) or struct.class.name.match(/Azure::.*?::Mgmt::.*?::Models::/) + rescue NameError + end + + if struct.is_a?(Struct) or struct.class.ancestors.include?(Struct) or + google_struct or aws_struct or azure_struct + + hash = if azure_struct + MU::Cloud::Azure.respToHash(struct) else - nil + struct.to_h end - rescue Aws::EC2::Errors::InternalError => e - MU.log "Got #{e.inspect} on MU::Cloud::AWS.ec2(region: #{MU.myRegion}).describe_instances(instance_ids: [#{@@myInstanceId}])", MU::WARN - sleep 10 + + if stringify_keys + newhash = {} + hash.each_pair { |k, v| + newhash[k.to_s] = v + } + hash = newhash + end + + hash.each_pair { |key, value| + hash[key] = self.structToHash(value, stringify_keys: stringify_keys) + } + return hash + elsif struct.is_a?(MU::Config::Ref) + struct = struct.to_h + elsif struct.is_a?(MU::Cloud::Azure::Id) + struct = struct.to_s + elsif struct.is_a?(Hash) + if stringify_keys + newhash = {} + struct.each_pair { |k, v| + newhash[k.to_s] = v + } + struct = newhash + end + struct.each_pair { |key, value| + struct[key] = self.structToHash(value, stringify_keys: stringify_keys) + } + return struct + elsif struct.is_a?(Array) + struct.map! { |elt| + self.structToHash(elt, stringify_keys: stringify_keys) + } + elsif struct.is_a?(String) + # Cleanse weird encoding problems + return struct.dup.to_s.force_encoding("ASCII-8BIT").encode('UTF-8', invalid: :replace, undef: :replace, replace: '?') + else + return struct end - @@myVPC_var end - @@mySubnets_var = nil - # The AWS Subnets associated with the VPC this MU Master is in - # XXX account for Google and non-cloud situations - def self.mySubnets - @@mySubnets_var ||= MU::Cloud::AWS.ec2(region: MU.myRegion).describe_subnets( - filters: [ - { - name: "vpc-id", - values: [MU.myVPC] - } - ] - ).subnets + @@myCloudDescriptor = nil + if MU.myCloud + svrclass = const_get("MU").const_get("Cloud").const_get(MU.myCloud).const_get("Server") + found = svrclass.find(cloud_id: @@myInstanceId, region: MU.myRegion) # XXX need habitat arg for google et al +# found = MU::MommaCat.findStray(MU.myCloud, "server", cloud_id: @@myInstanceId, dummy_ok: true, region: MU.myRegion) + if !found.nil? and found.size == 1 + @@myCloudDescriptor = found.values.first + end end - # The version of Chef we will install on nodes. - @@chefVersion = "14.0.190" - # The version of Chef we will install on nodes. - # @return [String] - def self.chefVersion; - @@chefVersion + + @@myVPCObj_var = nil + # The VPC/Network in which this Mu master resides + def self.myVPCObj + return nil if MU.myCloud.nil? + return @@myVPCObj_var if @@myVPCObj_var + cloudclass = const_get("MU").const_get("Cloud").const_get(MU.myCloud) + @@myVPCObj_var ||= cloudclass.myVPCObj + @@myVPCObj_var + end + + @@myVPC_var = nil + # The VPC/Network in which this Mu master resides + def self.myVPC + return nil if MU.myCloud.nil? + return @@myVPC_var if @@myVPC_var + my_vpc_desc = MU.myVPCObj + @@myVPC_var ||= my_vpc_desc.cloud_id if my_vpc_desc + @@myVPC_var end # Mu's SSL certificate directory @@ -589,20 +931,20 @@ def self.mySSLDir # @return [Boolean] def self.hashCmp(hash1, hash2, missing_is_default: false) return false if hash1.nil? - hash2.each_pair { |k, v| + hash2.keys.each { |k| if hash1[k].nil? return false end } if !missing_is_default - hash1.each_pair { |k, v| + hash1.keys.each { |k| if hash2[k].nil? return false end } end - hash1.each_pair { |k, v| + hash1.keys.each { |k| if hash1[k].is_a?(Array) return false if !missing_is_default and hash2[k].nil? if !hash2[k].nil? @@ -626,59 +968,48 @@ def self.hashCmp(hash1, hash2, missing_is_default: false) true end - # Recursively turn a Ruby OpenStruct into a Hash - # @param struct [OpenStruct] - # @return [Hash] - def self.structToHash(struct) - google_struct = false - begin - google_struct = struct.class.ancestors.include?(::Google::Apis::Core::Hashable) - rescue NameError - end - - aws_struct = false - begin - aws_struct = struct.class.ancestors.include?(::Seahorse::Client::Response) - rescue NameError - end - - if struct.is_a?(Struct) or struct.class.ancestors.include?(Struct) or - google_struct or aws_struct - - hash = struct.to_h - hash.each_pair { |key, value| - hash[key] = self.structToHash(value) - } - return hash - elsif struct.is_a?(Hash) - struct.each_pair { |key, value| - struct[key] = self.structToHash(value) + # Given a hash, or an array that might contain a hash, change all of the keys + # to symbols. Useful for formatting option parameters to some APIs. + def self.strToSym(obj) + if obj.is_a?(Hash) + newhash = {} + obj.each_pair { |k, v| + if v.is_a?(Hash) or v.is_a?(Array) + newhash[k.to_sym] = MU.strToSym(v) + else + newhash[k.to_sym] = v + end } - return struct - elsif struct.is_a?(Array) - struct.map! { |elt| - self.structToHash(elt) + newhash + elsif obj.is_a?(Array) + newarr = [] + obj.each { |v| + if v.is_a?(Hash) or v.is_a?(Array) + newarr << MU.strToSym(v) + else + newarr << v + end } - else - return struct + newarr end end + # Generate a random password which will satisfy the complexity requirements of stock Amazon Windows AMIs. # return [String]: A password string. - def self.generateWindowsPassword + def self.generateWindowsPassword(safe_pattern: '~!@#%^&*_-+=`|(){}[]:;<>,.?', retries: 25) # We have dopey complexity requirements, be stringent here. # I'll be nice and not condense this into one elegant-but-unreadable regular expression attempts = 0 - safe_metachars = Regexp.escape('~!@#%^&*_-+=`|(){}[]:;<>,.?') + safe_metachars = Regexp.escape(safe_pattern) begin - if attempts > 25 + if attempts > retries MU.log "Failed to generate an adequate Windows password after #{attempts}", MU::ERR raise MuError, "Failed to generate an adequate Windows password after #{attempts}" end winpass = Password.random(14..16) attempts += 1 - end while winpass.nil? or !winpass.match(/[A-Z]/) or !winpass.match(/[a-z]/) or !winpass.match(/\d/) or !winpass.match(/[#{safe_metachars}]/) or winpass.match(/[^\w\d#{safe_metachars}]/) + end while winpass.nil? or !winpass.match(/^[a-z]/i) or !winpass.match(/[A-Z]/) or !winpass.match(/[a-z]/) or !winpass.match(/\d/) or !winpass.match(/[#{safe_metachars}]/) or winpass.match(/[^\w\d#{safe_metachars}]/) MU.log "Generated Windows password after #{attempts} attempts", MU::DEBUG return winpass diff --git a/modules/mu/adoption.rb b/modules/mu/adoption.rb new file mode 100644 index 000000000..ffe597d35 --- /dev/null +++ b/modules/mu/adoption.rb @@ -0,0 +1,500 @@ +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module MU + + # Scrape cloud providers for existing resources, and reverse-engineer them + # into runnable {MU::Config} descriptors and/or {MU::MommaCat} deploy objects. + class Adoption + + attr_reader :found + + # Error class for objects which fail to fully resolve (e.g. references to + # other objects which are not found) + class Incomplete < MU::MuNonFatal; end + + # Presets methods we use to clump discovered resources into discrete deploys + GROUPMODES = { + :logical => "Group resources in logical layers (folders and habitats together, users/roles/groups together, network resources together, etc)", + :omnibus => "Jam everything into one monolothic configuration" + } + + def initialize(clouds: MU::Cloud.supportedClouds, types: MU::Cloud.resource_types.keys, parent: nil, billing: nil, sources: nil, credentials: nil, group_by: :logical, savedeploys: true, diff: false, habitats: []) + @scraped = {} + @clouds = clouds + @types = types + @parent = parent + @boks = {} + @billing = billing + @reference_map = {} + @sources = sources + @target_creds = credentials + @group_by = group_by + @savedeploys = savedeploys + @diff = diff + @habitats = habitats + @habitats ||= [] + end + + # Walk cloud providers with available credentials to discover resources + def scrapeClouds() + @default_parent = nil + + @clouds.each { |cloud| + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud) + next if cloudclass.listCredentials.nil? + + if cloud == "Google" and !@parent and @target_creds + dest_org = MU::Cloud::Google.getOrg(@target_creds) + if dest_org + @default_parent = dest_org.name + end + end + + cloudclass.listCredentials.each { |credset| + next if @sources and !@sources.include?(credset) + + if @parent +# TODO handle different inputs (cloud_id, etc) +# TODO do something about vague matches + found = MU::MommaCat.findStray( + cloud, + "folders", + flags: { "display_name" => @parent }, + credentials: credset, + allow_multi: false, + dummy_ok: true, + debug: false + ) + if found and found.size == 1 + @default_parent = found.first + end + end + + @types.each { |type| + begin + resclass = Object.const_get("MU").const_get("Cloud").const_get(cloud).const_get(type) + rescue ::MU::Cloud::MuCloudResourceNotImplemented + next + end + if !resclass.instance_methods.include?(:toKitten) + MU.log "Skipping MU::Cloud::#{cloud}::#{type} (resource has not implemented #toKitten)", MU::WARN + next + end + MU.log "Scraping #{cloud}/#{credset} for #{resclass.cfg_plural}" + + found = MU::MommaCat.findStray( + cloud, + type, + credentials: credset, + allow_multi: true, + habitats: @habitats.dup, + dummy_ok: true, + debug: false, + flags: { "skip_provider_owned" => true } + ) + + + if found and found.size > 0 + MU.log "Found #{found.size.to_s} raw #{resclass.cfg_plural} in #{cloud}" + @scraped[type] ||= {} + found.each { |obj| + # XXX apply any filters (e.g. MU-ID tags) + @scraped[type][obj.cloud_id] = obj + } + end + + } + } + } + + if @parent and !@default_parent + MU.log "Failed to locate a folder that resembles #{@parent}", MU::ERR + end + MU.log "Scraping complete" + end + + # Generate a {MU::Config} (Basket of Kittens) hash using our discovered + # cloud objects. + # @return [Hash] + def generateBaskets(prefix: "") + groupings = { + "" => MU::Cloud.resource_types.values.map { |v| v[:cfg_plural] } + } + + # XXX as soon as we come up with a method that isn't about what resource + # type you are, this code will stop making sense + if @group_by == :logical + groupings = { + "spaces" => ["folders", "habitats"], + "people" => ["users", "groups", "roles"], + "network" => ["vpcs", "firewall_rules", "dnszones"], + "storage" => ["storage_pools", "buckets"], + } + # "the movie star/and the rest" + groupings["services"] = MU::Cloud.resource_types.values.map { |v| v[:cfg_plural] } - groupings.values.flatten + elsif @group_by == :omnibus + prefix = "mu" if prefix.empty? # so that appnames aren't ever empty + end + + groupings.each_pair { |appname, types| + bok = { "appname" => prefix+appname } + if @target_creds + bok["credentials"] = @target_creds + end + + count = 0 + allowed_types = @types.map { |t| MU::Cloud.resource_types[t][:cfg_plural] } + next if (types & allowed_types).size == 0 + origin = { + "appname" => bok['appname'], + "types" => (types & allowed_types).sort, + "habitats" => @habitats.sort, + "group_by" => @group_by.to_s + } + + deploy = MU::MommaCat.findMatchingDeploy(origin) + if @diff and !deploy + MU.log "--diff was set but I failed to find a deploy like me to compare to", MU::ERR, details: origin + exit 1 + end + + threads = [] + @clouds.each { |cloud| + @scraped.each_pair { |type, resources| + res_class = begin + MU::Cloud.loadCloudType(cloud, type) + rescue MU::Cloud::MuCloudResourceNotImplemented => e + # XXX I don't think this can actually happen + next + end + next if !types.include?(res_class.cfg_plural) + + bok[res_class.cfg_plural] ||= [] + + class_semaphore = Mutex.new + + Thread.abort_on_exception = true + resources.each_pair { |cloud_id_thr, obj_thr| + threads << Thread.new(cloud_id_thr, obj_thr) { |cloud_id, obj| + + kitten_cfg = obj.toKitten(rootparent: @default_parent, billing: @billing, habitats: @habitats) + if kitten_cfg + print "." + kitten_cfg.delete("credentials") if @target_creds + class_semaphore.synchronize { + bok[res_class.cfg_plural] << kitten_cfg + } + count += 1 + end + } + + } + + threads.each { |t| + t.join + } + puts "" + bok[res_class.cfg_plural].sort! { |a, b| + strs = [a, b].map { |x| + if x['cloud_id'] + x['cloud_id'] + elsif x['parent'] and ['parent'].respond_to?(:id) and kitten_cfg['parent'].id + x['name']+x['parent'].id + elsif x['project'] + x['name']+x['project'] + else + x['name'] + end + } + strs[0] <=> strs[1] + } + + # If we've got duplicate names in here, try to deal with it + bok[res_class.cfg_plural].each { |kitten_cfg| + bok[res_class.cfg_plural].each { |sibling| + next if kitten_cfg == sibling + if sibling['name'] == kitten_cfg['name'] + MU.log "#{res_class.cfg_name} name #{sibling['name']} unavailable, will attempt to rename duplicate object", MU::DEBUG, details: kitten_cfg + if kitten_cfg['parent'] and kitten_cfg['parent'].respond_to?(:id) and kitten_cfg['parent'].id + kitten_cfg['name'] = kitten_cfg['name']+kitten_cfg['parent'].id + elsif kitten_cfg['project'] + kitten_cfg['name'] = kitten_cfg['name']+kitten_cfg['project'] + elsif kitten_cfg['cloud_id'] + kitten_cfg['name'] = kitten_cfg['name']+kitten_cfg['cloud_id'].gsub(/[^a-z0-9]/i, "-") + else + raise MU::Config::DuplicateNameError, "Saw duplicate #{res_class.cfg_name} name #{sibling['name']} and couldn't come up with a good way to differentiate them" + end + MU.log "De-duplication: Renamed #{res_class.cfg_name} name '#{sibling['name']}' => '#{kitten_cfg['name']}'", MU::NOTICE + break + end + } + } + } + } + + # No matching resources isn't necessarily an error + next if count == 0 or bok.nil? + +# Now walk through all of the Refs in these objects, resolve them, and minimize +# their config footprint + MU.log "Minimizing footprint of #{count.to_s} found resources", MU::DEBUG + @boks[bok['appname']] = vacuum(bok, origin: origin, save: @savedeploys) + + if @diff and !deploy + MU.log "diff flag set, but no comparable deploy provided for #{bok['appname']}", MU::ERR + exit 1 + end + + if deploy and @diff + prevcfg = MU::Config.manxify(vacuum(deploy.original_config, deploy: deploy)) + if !prevcfg + MU.log "#{deploy.deploy_id} didn't have a working original config for me to compare", MU::ERR + exit 1 + end + newcfg = MU::Config.manxify(@boks[bok['appname']]) + + prevcfg.diff(newcfg) + exit + end + } + @boks + end + + private + + # Recursively walk through a BoK hash, validate all {MU::Config::Ref} + # objects, convert them to hashes, and pare them down to the minimal + # representation (remove extraneous attributes that match the parent + # object). + # Do the same for our main objects: if they all use the same credentials, + # for example, remove the explicit +credentials+ attributes and set that + # value globally, once. + def vacuum(bok, origin: nil, save: false, deploy: nil) + deploy ||= generateStubDeploy(bok) + + globals = { + 'cloud' => {}, + 'credentials' => {}, + 'region' => {}, + 'billing_acct' => {}, + 'us_only' => {}, + } + clouds = {} + credentials = {} + regions = {} + MU::Cloud.resource_types.each_pair { |typename, attrs| + if bok[attrs[:cfg_plural]] + processed = [] + bok[attrs[:cfg_plural]].each { |resource| + globals.each_pair { |field, counts| + if resource[field] + counts[resource[field]] ||= 0 + counts[resource[field]] += 1 + end + } + obj = deploy.findLitterMate(type: attrs[:cfg_plural], name: resource['name']) + begin + processed << resolveReferences(resource, deploy, obj) + rescue Incomplete + end + resource.delete("cloud_id") + } + deploy.original_config[attrs[:cfg_plural]] = processed + bok[attrs[:cfg_plural]] = processed + end + } + + # Pare out global values like +cloud+ or +region+ that appear to be + # universal in the deploy we're creating. + def scrub_globals(h, field) + if h.is_a?(Hash) + newhash = {} + h.each_pair { |k, v| + next if k == field + newhash[k] = scrub_globals(v, field) + } + h = newhash + elsif h.is_a?(Array) + newarr = [] + h.each { |v| + newarr << scrub_globals(v, field) + } + h = newarr + end + + h + end + + globals.each_pair { |field, counts| + next if counts.size != 1 + bok[field] = counts.keys.first + MU.log "Setting global default #{field} to #{bok[field]} (#{deploy.deploy_id})", MU::DEBUG + MU::Cloud.resource_types.each_pair { |typename, attrs| + if bok[attrs[:cfg_plural]] + new_resources = [] + bok[attrs[:cfg_plural]].each { |resource| + new_resources << scrub_globals(resource, field) + } + bok[attrs[:cfg_plural]] = new_resources + end + } + } + + if save + MU.log "Committing adopted deployment to #{MU.dataDir}/deployments/#{deploy.deploy_id}", MU::NOTICE, details: origin + deploy.save!(force: true, origin: origin) + end + + bok + end + + def resolveReferences(cfg, deploy, parent) + if cfg.is_a?(MU::Config::Ref) + hashcfg = cfg.to_h + if cfg.kitten(deploy) + littermate = deploy.findLitterMate(type: cfg.type, name: cfg.name, cloud_id: cfg.id, habitat: cfg.habitat) + if littermate and littermate.config['name'] + hashcfg['name'] = littermate.config['name'] + hashcfg.delete("id") if hashcfg["name"] + hashcfg + elsif cfg.deploy_id and cfg.name and @savedeploys + hashcfg.delete("id") if hashcfg["name"] + hashcfg + elsif cfg.id + littermate = deploy.findLitterMate(type: cfg.type, cloud_id: cfg.id, habitat: cfg.habitat) + if littermate and littermate.config['name'] + hashcfg['name'] = littermate.config['name'] + hashcfg.delete("id") if hashcfg["name"] + elsif !@savedeploys + hashcfg.delete("deploy_id") + hashcfg.delete("name") + else + hashcfg.delete("name") if cfg.id and !cfg.deploy_id + end + end + elsif hashcfg["id"] # reference to raw cloud ids is reasonable + hashcfg.delete("deploy_id") + hashcfg.delete("name") + else + pp parent.cloud_desc + raise Incomplete, "Failed to resolve reference on behalf of #{parent}" + end + hashcfg.delete("deploy_id") if hashcfg['deploy_id'] == deploy.deploy_id + cfg = hashcfg + elsif cfg.is_a?(Hash) + deletia = [] + cfg.each_pair { |key, value| + begin + cfg[key] = resolveReferences(value, deploy, parent) + rescue Incomplete + MU.log "Dropping unresolved key #{key}", MU::WARN, details: cfg + deletia << key + end + } + deletia.each { |key| + cfg.delete(key) + } + cfg = nil if cfg.empty? and deletia.size > 0 + elsif cfg.is_a?(Array) + new_array = [] + cfg.each { |value| + begin + new_item = resolveReferences(value, deploy, parent) + if !new_item + MU.log "Dropping unresolved value", MU::WARN, details: value + else + new_array << new_item + end + rescue Incomplete + MU.log "Dropping unresolved value", MU::WARN, details: value + end + } + cfg = new_array + end + + cfg + end + + # @return [MU::MommaCat] + def generateStubDeploy(bok) +# hashify Ref objects before passing into here... or do we...? + + time = Time.new + timestamp = time.strftime("%Y%m%d%H").to_s; + timestamp.freeze + + retries = 0 + deploy_id = nil + seed = nil + begin + raise MuError, "Failed to allocate an unused MU-ID after #{retries} tries!" if retries > 70 + seedsize = 1 + (retries/10).abs + seed = (0...seedsize+1).map { ('a'..'z').to_a[rand(26)] }.join + deploy_id = bok['appname'].upcase + "-ADOPT-" + timestamp + "-" + seed.upcase + end while MU::MommaCat.deploy_exists?(deploy_id) or seed == "mu" or seed[0] == seed[1] + + MU.setVar("deploy_id", deploy_id) + MU.setVar("appname", bok['appname'].upcase) + MU.setVar("environment", "ADOPT") + MU.setVar("timestamp", timestamp) + MU.setVar("seed", seed) + MU.setVar("handle", MU::MommaCat.generateHandle(seed)) + + deploy = MU::MommaCat.new( + deploy_id, + create: true, + config: bok, + environment: "adopt", + appname: bok['appname'].upcase, + timestamp: timestamp, + nocleanup: true, + no_artifacts: !(@savedeploys), + set_context_to_me: true, + mu_user: MU.mu_user + ) + + MU::Cloud.resource_types.each_pair { |typename, attrs| + if bok[attrs[:cfg_plural]] + bok[attrs[:cfg_plural]].each { |kitten| + + if !@scraped[typename][kitten['cloud_id']] + MU.log "No object in scraped tree for #{attrs[:cfg_name]} #{kitten['cloud_id']} (#{kitten['name']})", MU::ERR, details: kitten + next + end + + MU.log "Inserting #{attrs[:cfg_name]} #{kitten['name']} (#{kitten['cloud_id']}) into stub deploy", MU::DEBUG, details: @scraped[typename][kitten['cloud_id']] + + @scraped[typename][kitten['cloud_id']].config!(kitten) + + deploy.addKitten( + attrs[:cfg_plural], + kitten['name'], + @scraped[typename][kitten['cloud_id']] + ) + } + end + } + + deploy + end + + # Go through everything we've scraped and update our mappings of cloud ids + # and bare name fields, so that resources can reference one another + # portably by name. + def catalogResources + end + + end +end diff --git a/modules/mu/cleanup.rb b/modules/mu/cleanup.rb index df4f932e8..e5f95953e 100644 --- a/modules/mu/cleanup.rb +++ b/modules/mu/cleanup.rb @@ -41,7 +41,7 @@ class Cleanup # @param web [Boolean]: Generate web-friendly output. # @param ignoremaster [Boolean]: Ignore the tags indicating the originating MU master server when deleting. # @return [void] - def self.run(deploy_id, noop: false, skipsnapshots: false, onlycloud: false, verbosity: MU::Logger::NORMAL, web: false, ignoremaster: false, skipcloud: false, mommacat: nil) + def self.run(deploy_id, noop: false, skipsnapshots: false, onlycloud: false, verbosity: MU::Logger::NORMAL, web: false, ignoremaster: false, skipcloud: false, mommacat: nil, credsets: nil, regions: nil) MU.setLogging(verbosity, web) @noop = noop @skipsnapshots = skipsnapshots @@ -61,7 +61,7 @@ def self.run(deploy_id, noop: false, skipsnapshots: false, onlycloud: false, ver end - types_in_order = ["Collection", "Endpoint", "Function", "ServerPool", "ContainerCluster", "SearchDomain", "Server", "MsgQueue", "Database", "CacheCluster", "StoragePool", "LoadBalancer", "NoSQLDB", "FirewallRule", "Alarm", "Notifier", "Log", "VPC", "Role", "Group", "User", "Bucket", "DNSZone", "Collection", "Habitat", "Folder"] + types_in_order = ["Collection", "Endpoint", "Function", "ServerPool", "ContainerCluster", "SearchDomain", "Server", "MsgQueue", "Database", "CacheCluster", "StoragePool", "LoadBalancer", "NoSQLDB", "FirewallRule", "Alarm", "Notifier", "Log", "VPC", "Role", "Group", "User", "Bucket", "DNSZone", "Collection"] # Load up our deployment metadata if !mommacat.nil? @@ -75,190 +75,229 @@ def self.run(deploy_id, noop: false, skipsnapshots: false, onlycloud: false, ver FileUtils.touch("#{deploy_dir}/.cleanup") if !@noop else MU.log "I don't see a deploy named #{deploy_id}.", MU::WARN - MU.log "Known deployments:\n#{Dir.entries(deploy_dir).reject { |item| item.match(/^\./) or !File.exists?(deploy_dir+"/"+item+"/public_key") }.join("\n")}", MU::WARN + MU.log "Known deployments:\n#{Dir.entries(deploy_dir).reject { |item| item.match(/^\./) or !File.exist?(deploy_dir+"/"+item+"/public_key") }.join("\n")}", MU::WARN MU.log "Searching for remnants of #{deploy_id}, though this may be an invalid MU-ID.", MU::WARN end - @mommacat = MU::MommaCat.new(deploy_id, mu_user: MU.mu_user) + @mommacat = MU::MommaCat.new(deploy_id, mu_user: MU.mu_user, delay_descriptor_load: true) rescue Exception => e MU.log "Can't load a deploy record for #{deploy_id} (#{e.inspect}), cleaning up resources by guesswork", MU::WARN, details: e.backtrace MU.setVar("deploy_id", deploy_id) + end end + regionsused = @mommacat.regionsUsed if @mommacat + credsused = @mommacat.credsUsed if @mommacat + if !@skipcloud creds = {} - MU::Cloud.supportedClouds.each { |cloud| + MU::Cloud.availableClouds.each { |cloud| if $MU_CFG[cloud.downcase] and $MU_CFG[cloud.downcase].size > 0 cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud) creds[cloud] ||= {} cloudclass.listCredentials.each { |credset| + next if credsets and credsets.size > 0 and !credsets.include?(credset) + next if credsused and credsused.size > 0 and !credsused.include?(credset) + MU.log "Will scan #{cloud} with credentials #{credset}" creds[cloud][credset] = cloudclass.listRegions(credentials: credset) } end } + parent_thread_id = Thread.current.object_id deleted_nodes = 0 - @regionthreads = [] + cloudthreads = [] keyname = "deploy-#{MU.deploy_id}" -# XXX blindly checking for all of these resources in all clouds is now prohibitively slow. We should only do this when we don't see deployment metadata to work from. - creds.each_pair { |provider, credsets| - credsets.each_pair { |credset, regions| - global_vs_region_semaphore = Mutex.new - global_done = [] - regions.each { |r| - @regionthreads << Thread.new { - MU.dupGlobals(parent_thread_id) - MU.setVar("curRegion", r) - projects = [] - if $MU_CFG[provider.downcase][credset]["project"] -# XXX GCP credential schema needs an array for projects - projects << $MU_CFG[provider.downcase][credset]["project"] - end - if projects == [] - projects << "" # dummy - MU.log "Checking for #{provider}/#{credset} resources from #{MU.deploy_id} in #{r}", MU::NOTICE + creds.each_pair { |provider, credsets_outer| + cloudthreads << Thread.new(provider, credsets_outer) { |cloud, credsets_inner| + MU.dupGlobals(parent_thread_id) + Thread.abort_on_exception = false + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud) + habitatclass = Object.const_get("MU").const_get("Cloud").const_get(cloud).const_get("Habitat") + credsets_inner.each_pair { |credset, acct_regions| + next if credsused and !credsused.include?(credset) + global_vs_region_semaphore = Mutex.new + global_done = {} + habitats_done = {} + regionthreads = [] + acct_regions.each { |r| + if regionsused + if regionsused.size > 0 + next if !regionsused.include?(r) + else + next if r != cloudclass.myRegion(credset) + end end - - # We do these in an order that unrolls dependent resources - # sensibly, and we hit :Collection twice because AWS - # CloudFormation sometimes fails internally. - projectthreads = [] - projects.each { |project| - projectthreads << Thread.new { - MU.dupGlobals(parent_thread_id) - MU.setVar("curRegion", r) - if project != "" - MU.log "Checking for #{provider}/#{credset} resources from #{MU.deploy_id} in #{r}, project #{project}", MU::NOTICE - end - - MU.dupGlobals(parent_thread_id) - flags = { - "project" => project, - "onlycloud" => @onlycloud, - "skipsnapshots" => @skipsnapshots, - } - types_in_order.each { |t| - begin - skipme = false - global_vs_region_semaphore.synchronize { - if Object.const_get("MU").const_get("Cloud").const_get(provider).const_get(t).isGlobal? - if !global_done.include?(t) - global_done << t - flags['global'] = true - else - skipme = true - end - end - } - next if skipme - rescue MU::Cloud::MuCloudResourceNotImplemented => e - next - rescue MU::MuError, NoMethodError => e - MU.log e.message, MU::WARN - next - rescue ::Aws::EC2::Errors::AuthFailure => e - # AWS has been having transient auth problems with ap-east-1 lately - MU.log e.message+" in "+r, MU::ERR - next + if regions and !regions.empty? + next if !regions.include?(r) + MU.log "Checking for #{cloud}/#{credset} resources from #{MU.deploy_id} in #{r}...", MU::NOTICE + end + regionthreads << Thread.new { + MU.dupGlobals(parent_thread_id) + Thread.abort_on_exception = false + MU.setVar("curRegion", r) + projects = [] + if $MU_CFG[cloud.downcase][credset]["project"] +# XXX GCP credential schema needs an array for projects + projects << $MU_CFG[cloud.downcase][credset]["project"] + end + begin + projects.concat(cloudclass.listProjects(credset)) + rescue NoMethodError + end + + if projects == [] + projects << "" # dummy + MU.log "Checking for #{cloud}/#{credset} resources from #{MU.deploy_id} in #{r}", MU::NOTICE + end + projects.uniq! + + # We do these in an order that unrolls dependent resources + # sensibly, and we hit :Collection twice because AWS + # CloudFormation sometimes fails internally. + projectthreads = [] + projects.each { |project| + next if !habitatclass.isLive?(project, credset) + + projectthreads << Thread.new { + MU.dupGlobals(parent_thread_id) + MU.setVar("curRegion", r) + Thread.abort_on_exception = false + if project != "" + MU.log "Checking for #{cloud}/#{credset} resources from #{MU.deploy_id} in #{r}, project #{project}", MU::NOTICE end - if @mommacat.nil? or @mommacat.numKittens(types: [t]) > 0 - if @mommacat - found = @mommacat.findLitterMate(type: t, return_all: true, credentials: credset) - flags['known'] ||= [] - if found.is_a?(Array) - found.each { |k| - flags['known'] << k.cloud_id - } - elsif found and found.is_a?(Hash) - flags['known'] << found['cloud_id'] - elsif found - flags['known'] << found.cloud_id - end + MU.dupGlobals(parent_thread_id) + flags = { + "project" => project, + "onlycloud" => @onlycloud, + "skipsnapshots" => @skipsnapshots, + } + types_in_order.each { |t| + shortclass, cfg_name, cfg_plural, classname = MU::Cloud.getResourceNames(t) + begin + skipme = false + global_vs_region_semaphore.synchronize { + MU::Cloud.loadCloudType(cloud, t) + if Object.const_get("MU").const_get("Cloud").const_get(cloud).const_get(t).isGlobal? + global_done[project] ||= [] + if !global_done[project].include?(t) + global_done[project] << t + flags['global'] = true + else + skipme = true + end + end + } + next if skipme + rescue MU::Cloud::MuDefunctHabitat, MU::Cloud::MuCloudResourceNotImplemented => e + next + rescue MU::MuError, NoMethodError => e + MU.log "While checking mu/clouds/#{cloud.downcase}/#{cloudclass.cfg_name} for global-ness in cleanup: "+e.message, MU::WARN + next + rescue ::Aws::EC2::Errors::AuthFailure, ::Google::Apis::ClientError => e + MU.log e.message+" in "+r, MU::ERR + next end + begin - resclass = Object.const_get("MU").const_get("Cloud").const_get(t) - resclass.cleanup( - noop: @noop, - ignoremaster: @ignoremaster, - region: r, - cloud: provider, - flags: flags, - credentials: credset - ) - rescue Seahorse::Client::NetworkingError => e - MU.log "Service not available in AWS region #{r}, skipping", MU::DEBUG, details: e.message + self.call_cleanup(t, credset, cloud, flags, r) + rescue MU::Cloud::MuDefunctHabitat, MU::Cloud::MuCloudResourceNotImplemented => e + next end - end + } + } # types_in_order.each { |t| + } # projects.each { |project| + projectthreads.each do |t| + t.join + end + + # XXX move to MU::AWS + if cloud == "AWS" + resp = MU::Cloud::AWS.ec2(region: r, credentials: credset).describe_key_pairs( + filters: [{name: "key-name", values: [keyname]}] + ) + resp.data.key_pairs.each { |keypair| + MU.log "Deleting key pair #{keypair.key_name} from #{r}" + MU::Cloud::AWS.ec2(region: r, credentials: credset).delete_key_pair(key_name: keypair.key_name) if !@noop } - } - } - projectthreads.each do |t| - t.join - end + end + } # regionthreads << Thread.new { + } # acct_regions.each { |r| + regionthreads.each do |t| + t.join + end - # XXX move to MU::AWS - if provider == "AWS" - resp = MU::Cloud::AWS.ec2(region: r, credentials: credset).describe_key_pairs( - filters: [{name: "key-name", values: [keyname]}] - ) - resp.data.key_pairs.each { |keypair| - MU.log "Deleting key pair #{keypair.key_name} from #{r}" - MU::Cloud::AWS.ec2(region: r, credentials: credset).delete_key_pair(key_name: keypair.key_name) if !@noop - } - end + } # credsets.each_pair { |credset, acct_regions| + } # cloudthreads << Thread.new(provider, credsets) { |cloud, credsets_outer| + cloudthreads.each do |t| + t.join + end + } # creds.each_pair { |provider, credsets| + + + # Knock habitats and folders, which would contain the above resources, + # once they're all done. + creds.each_pair { |provider, credsets_inner| + credsets_inner.keys.each { |credset| + next if credsused and !credsused.include?(credset) + ["Habitat", "Folder"].each { |t| + flags = { + "onlycloud" => @onlycloud, + "skipsnapshots" => @skipsnapshots } + self.call_cleanup(t, credset, provider, flags, nil) } } } - @regionthreads.each do |t| - t.join - end - @projectthreads = [] - - - @projectthreads.each do |t| - t.join - end - MU::Cloud::Google.removeDeploySecretsAndRoles(MU.deploy_id) # XXX port AWS equivalent behavior and add a MU::Cloud wrapper + + creds.each_pair { |provider, credsets_inner| + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(provider) + credsets_inner.keys.each { |c| + cloudclass.cleanDeploy(MU.deploy_id, credentials: c, noop: @noop) + } + } end # Scrub any residual Chef records with matching tags - if !@onlycloud and (@mommacat.nil? or @mommacat.numKittens(types: ["Server", "ServerPool"]) > 0) - MU::Groomer::Chef.loadChefLib - if File.exists?(Etc.getpwuid(Process.uid).dir+"/.chef/knife.rb") - Chef::Config.from_file(Etc.getpwuid(Process.uid).dir+"/.chef/knife.rb") - end - deadnodes = [] - Chef::Config[:environment] = MU.environment - q = Chef::Search::Query.new + if !@onlycloud and (@mommacat.nil? or @mommacat.numKittens(types: ["Server", "ServerPool"]) > 0) and !(Gem.paths and Gem.paths.home and !Dir.exist?("/opt/mu/lib")) begin - q.search("node", "tags_MU-ID:#{MU.deploy_id}").each { |item| - next if item.is_a?(Integer) - item.each { |node| - deadnodes << node.name + MU::Groomer::Chef.loadChefLib + if File.exist?(Etc.getpwuid(Process.uid).dir+"/.chef/knife.rb") + Chef::Config.from_file(Etc.getpwuid(Process.uid).dir+"/.chef/knife.rb") + end + deadnodes = [] + Chef::Config[:environment] = MU.environment + q = Chef::Search::Query.new + begin + q.search("node", "tags_MU-ID:#{MU.deploy_id}").each { |item| + next if item.is_a?(Integer) + item.each { |node| + deadnodes << node.name + } } - } - rescue Net::HTTPServerException - end + rescue Net::HTTPServerException + end - begin - q.search("node", "name:#{MU.deploy_id}-*").each { |item| - next if item.is_a?(Integer) - item.each { |node| - deadnodes << node.name + begin + q.search("node", "name:#{MU.deploy_id}-*").each { |item| + next if item.is_a?(Integer) + item.each { |node| + deadnodes << node.name + } } + rescue Net::HTTPServerException + end + MU.log "Missed some Chef resources in node cleanup, purging now", MU::NOTICE if deadnodes.size > 0 + deadnodes.uniq.each { |node| + MU::Groomer::Chef.cleanup(node, [], noop) } - rescue Net::HTTPServerException + rescue LoadError end - MU.log "Missed some Chef resources in node cleanup, purging now", MU::NOTICE if deadnodes.size > 0 - deadnodes.uniq.each { |node| - MU::Groomer::Chef.cleanup(node, [], noop) - } end if !@onlycloud and !@noop and @mommacat @@ -270,18 +309,18 @@ def self.run(deploy_id, noop: false, skipsnapshots: false, onlycloud: false, ver sshconf = "#{sshdir}/config" ssharchive = "#{sshdir}/archive" - Dir.mkdir(sshdir, 0700) if !Dir.exists?(sshdir) and !@noop - Dir.mkdir(ssharchive, 0700) if !Dir.exists?(ssharchive) and !@noop + Dir.mkdir(sshdir, 0700) if !Dir.exist?(sshdir) and !@noop + Dir.mkdir(ssharchive, 0700) if !Dir.exist?(ssharchive) and !@noop keyname = "deploy-#{MU.deploy_id}" - if File.exists?("#{sshdir}/#{keyname}") + if File.exist?("#{sshdir}/#{keyname}") MU.log "Moving #{sshdir}/#{keyname} to #{ssharchive}/#{keyname}" if !@noop File.rename("#{sshdir}/#{keyname}", "#{ssharchive}/#{keyname}") end end - if File.exists?(sshconf) and File.open(sshconf).read.match(/\/deploy\-#{MU.deploy_id}$/) + if File.exist?(sshconf) and File.open(sshconf).read.match(/\/deploy\-#{MU.deploy_id}$/) MU.log "Expunging #{MU.deploy_id} from #{sshconf}" if !@noop FileUtils.copy(sshconf, "#{ssharchive}/config-#{MU.deploy_id}") @@ -309,21 +348,25 @@ def self.run(deploy_id, noop: false, skipsnapshots: false, onlycloud: false, ver # XXX refactor with above? They're similar, ish. hostsfile = "/etc/hosts" if File.open(hostsfile).read.match(/ #{MU.deploy_id}\-/) - MU.log "Expunging traces of #{MU.deploy_id} from #{hostsfile}" - if !@noop - FileUtils.copy(hostsfile, "#{hostsfile}.cleanup-#{deploy_id}") - File.open(hostsfile, File::CREAT|File::RDWR, 0644) { |f| - f.flock(File::LOCK_EX) - newlines = Array.new - f.readlines.each { |line| - newlines << line if !line.match(/ #{MU.deploy_id}\-/) + if Process.uid == 0 + MU.log "Expunging traces of #{MU.deploy_id} from #{hostsfile}" + if !@noop + FileUtils.copy(hostsfile, "#{hostsfile}.cleanup-#{deploy_id}") + File.open(hostsfile, File::CREAT|File::RDWR, 0644) { |f| + f.flock(File::LOCK_EX) + newlines = Array.new + f.readlines.each { |line| + newlines << line if !line.match(/ #{MU.deploy_id}\-/) + } + f.rewind + f.truncate(0) + f.puts(newlines) + f.flush + f.flock(File::LOCK_UN) } - f.rewind - f.truncate(0) - f.puts(newlines) - f.flush - f.flock(File::LOCK_UN) - } + end + else + MU.log "Residual /etc/hosts entries for #{MU.deploy_id} must be removed by root user", MU::WARN end end @@ -354,5 +397,39 @@ def self.run(deploy_id, noop: false, skipsnapshots: false, onlycloud: false, ver end end + + private + + def self.call_cleanup(type, credset, provider, flags, region) + if @mommacat.nil? or @mommacat.numKittens(types: [type]) > 0 + if @mommacat + found = @mommacat.findLitterMate(type: type, return_all: true, credentials: credset) + flags['known'] ||= [] + if found.is_a?(Array) + found.each { |k| + flags['known'] << k.cloud_id + } + elsif found and found.is_a?(Hash) + flags['known'] << found['cloud_id'] + elsif found + flags['known'] << found.cloud_id + end + end +# begin + resclass = Object.const_get("MU").const_get("Cloud").const_get(type) + + resclass.cleanup( + noop: @noop, + ignoremaster: @ignoremaster, + region: region, + cloud: provider, + flags: flags, + credentials: credset + ) +# rescue ::Seahorse::Client::NetworkingError => e +# MU.log "Service not available in AWS region #{r}, skipping", MU::DEBUG, details: e.message +# end + end + end end #class end #module diff --git a/modules/mu/cloud.rb b/modules/mu/cloud.rb index b31aae6b0..121dae1a4 100644 --- a/modules/mu/cloud.rb +++ b/modules/mu/cloud.rb @@ -39,12 +39,40 @@ class MuCloudResourceNotImplemented < StandardError; class MuCloudFlagNotImplemented < StandardError; end + # Exception we throw when we attempt to make an API call against a project + # that is already deleted. + class MuDefunctHabitat < StandardError; + end + # Methods which a cloud resource implementation, e.g. Server, must implement generic_class_methods = [:find, :cleanup, :validateConfig, :schema, :isGlobal?] generic_instance_methods = [:create, :notify, :mu_name, :cloud_id, :config] # Class methods which the base of a cloud implementation must implement - generic_class_methods_toplevel = [:required_instance_methods, :myRegion, :listRegions, :listAZs, :hosted?, :hosted_config, :config_example, :writeDeploySecret, :listCredentials, :credConfig, :listInstanceTypes, :adminBucketName, :adminBucketUrl] + generic_class_methods_toplevel = [:required_instance_methods, :myRegion, :listRegions, :listAZs, :hosted?, :hosted_config, :config_example, :writeDeploySecret, :listCredentials, :credConfig, :listInstanceTypes, :adminBucketName, :adminBucketUrl, :habitat] + + # Public attributes which will be available on all instantiated cloud resource objects + # + # +:config+: The fully-resolved {MU::Config} hash describing the object, aka the Basket of Kittens entry + # + # +:mu_name+: The unique internal name of the object, if one already exists + # + # +:cloud+: The cloud in which this object is resident + # + # +:cloud_id+: The cloud provider's official identifier for this object + # + # +:environment+: The declared environment string for the deployment of which this object is a member + # + # +:deploy:+ The {MU::MommaCat} object representing the deployment of which this object is a member + # + # +:deploy_id:+ The unique string which identifies the deployment of which this object is a member + # + # +:deploydata:+ A Hash containing all metadata reported by resources in this deploy method, via their +notify+ methods + # + # +:appname:+ The declared application name of this deployment + # + # +:credentials:+ The name of the cloud provider credential set from +mu.yaml+ which is used to manage this object + PUBLIC_ATTRS = [:config, :mu_name, :cloud, :cloud_id, :environment, :deploy, :deploy_id, :deploydata, :appname, :credentials] # Initialize empty classes for each of these. We'll fill them with code # later; we're doing this here because otherwise the parser yells about @@ -161,7 +189,7 @@ class NoSQLDB; :interface => self.const_get("Habitat"), :deps_wait_on_my_creation => true, :waits_on_parent_completion => true, - :class => generic_class_methods, + :class => generic_class_methods + [:isLive?], :instance => generic_instance_methods + [:groom] }, :Collection => { @@ -217,7 +245,7 @@ class NoSQLDB; :deps_wait_on_my_creation => true, :waits_on_parent_completion => false, :class => generic_class_methods, - :instance => generic_instance_methods + [:registerNode] + :instance => generic_instance_methods + [:groom, :registerNode] }, :Server => { :has_multiples => true, @@ -227,8 +255,8 @@ class NoSQLDB; :interface => self.const_get("Server"), :deps_wait_on_my_creation => false, :waits_on_parent_completion => false, - :class => generic_class_methods + [:validateInstanceType], - :instance => generic_instance_methods + [:groom, :postBoot, :getSSHConfig, :canonicalIP, :getWindowsAdminPassword, :active?, :groomer, :mu_windows_name, :mu_windows_name=, :reboot, :addVolume] + :class => generic_class_methods + [:validateInstanceType, :imageTimeStamp], + :instance => generic_instance_methods + [:groom, :postBoot, :getSSHConfig, :canonicalIP, :getWindowsAdminPassword, :active?, :groomer, :mu_windows_name, :mu_windows_name=, :reboot, :addVolume, :genericNAT] }, :ServerPool => { :has_multiples => false, @@ -403,8 +431,8 @@ class NoSQLDB; :interface => self.const_get("Bucket"), :deps_wait_on_my_creation => true, :waits_on_parent_completion => true, - :class => generic_class_methods, - :instance => generic_instance_methods + [:groom] + :class => generic_class_methods + [:upload], + :instance => generic_instance_methods + [:groom, :upload] }, :NoSQLDB => { :has_multiples => false, @@ -419,6 +447,195 @@ class NoSQLDB; } }.freeze + # The public AWS S3 bucket where we expect to find YAML files listing our + # standard base images for various platforms. + BASE_IMAGE_BUCKET = "cloudamatic" + # The path in the AWS S3 bucket where we expect to find YAML files listing + # our standard base images for various platforms. + BASE_IMAGE_PATH = "/images" + + # Aliases for platform names, in case we don't have actual images built for + # them. + PLATFORM_ALIASES = { + "linux" => "centos7", + "windows" => "win2k12r2", + "win2k12" => "win2k12r2", + "ubuntu" => "ubuntu16", + "centos" => "centos7", + "rhel7" => "rhel71", + "rhel" => "rhel71", + "amazon" => "amazon2016" + } + + @@image_fetch_cache = {} + @@platform_cache = [] + @@image_fetch_semaphore = Mutex.new + + # Rifle our image lists from {MU::Cloud.getStockImage} and return a list + # of valid +platform+ names. + # @return [Array] + def self.listPlatforms + return @@platform_cache if @@platform_cache and !@@platform_cache.empty? + @@platform_cache = MU::Cloud.supportedClouds.map { |cloud| + begin + loadCloudType(cloud, :Server) + rescue MU::Cloud::MuCloudResourceNotImplemented, MU::MuError => e + next + end + + images = MU::Cloud.getStockImage(cloud, quiet: true) + if images + images.keys + else + nil + end + }.flatten.uniq + @@platform_cache.delete(nil) + @@platform_cache.sort + @@platform_cache + end + + # Locate a base image for a {MU::Cloud::Server} resource. First we check + # Mu's public bucket, which should list the latest and greatest. If we can't + # fetch that, then we fall back to a YAML file that's bundled as part of Mu, + # but which will typically be less up-to-date. + # @param cloud [String]: The cloud provider for which to return an image list + # @param platform [String]: The supported platform for which to return an image or images. If not specified, we'll return our entire library for the appropriate cloud provider. + # @param region [String]: The region for which the returned image or images should be supported, for cloud providers which require it (such as AWS). + # @param fail_hard [Boolean]: Raise an exception on most errors, such as an inability to reach our public listing, lack of matching images, etc. + # @return [Hash,String,nil] + def self.getStockImage(cloud = MU::Config.defaultCloud, platform: nil, region: nil, fail_hard: false, quiet: false) + + if !MU::Cloud.supportedClouds.include?(cloud) + MU.log "'#{cloud}' is not a supported cloud provider! Available providers:", MU::ERR, details: MU::Cloud.supportedClouds + raise MuError, "'#{cloud}' is not a supported cloud provider!" + end + + urls = ["http://"+BASE_IMAGE_BUCKET+".s3-website-us-east-1.amazonaws.com"+BASE_IMAGE_PATH] + if $MU_CFG and $MU_CFG['custom_images_url'] + urls << $MU_CFG['custom_images_url'] + end + + images = nil + urls.each { |base_url| + @@image_fetch_semaphore.synchronize { + if @@image_fetch_cache[cloud] and (Time.now - @@image_fetch_cache[cloud]['time']) < 30 + images = @@image_fetch_cache[cloud]['contents'].dup + else + begin + Timeout.timeout(2) do + response = open("#{base_url}/#{cloud}.yaml").read + images ||= {} + images.deep_merge!(YAML.load(response)) + break + end + rescue Exception => e + if fail_hard + raise MuError, "Failed to fetch stock images from #{base_url}/#{cloud}.yaml (#{e.message})" + else + MU.log "Failed to fetch stock images from #{base_url}/#{cloud}.yaml (#{e.message})", MU::WARN if !quiet + end + end + end + } + } + + @@image_fetch_semaphore.synchronize { + @@image_fetch_cache[cloud] = { + 'contents' => images.dup, + 'time' => Time.now + } + } + + backwards_compat = { + "AWS" => "amazon_images", + "Google" => "google_images", + } + + # Load from inside our repository, if we didn't get images elsewise + if images.nil? + [backwards_compat[cloud], cloud].each { |file| + next if file.nil? + if File.exist?("#{MU.myRoot}/modules/mu/defaults/#{file}.yaml") + images = YAML.load(File.read("#{MU.myRoot}/modules/mu/defaults/#{file}.yaml")) + break + end + } + end + + # Now overlay local overrides, both of the systemwide (/opt/mu/etc) and + # per-user (~/.mu/etc) variety. + [backwards_compat[cloud], cloud].each { |file| + next if file.nil? + if File.exist?("#{MU.etcDir}/#{file}.yaml") + images ||= {} + images.deep_merge!(YAML.load(File.read("#{MU.etcDir}/#{file}.yaml"))) + end + if Process.uid != 0 + basepath = Etc.getpwuid(Process.uid).dir+"/.mu/etc" + if File.exist?("#{basepath}/#{file}.yaml") + images ||= {} + images.deep_merge!(YAML.load(File.read("#{basepath}/#{file}.yaml"))) + end + end + } + + if images.nil? + if fail_hard + raise MuError, "Failed to find any base images for #{cloud}" + else + MU.log "Failed to find any base images for #{cloud}", MU::WARN if !quiet + return nil + end + end + + PLATFORM_ALIASES.each_pair { |a, t| + if images[t] and !images[a] + images[a] = images[t] + end + } + + if platform + if !images[platform] + if fail_hard + raise MuError, "No base image for platform #{platform} in cloud #{cloud}" + else + MU.log "No base image for platform #{platform} in cloud #{cloud}", MU::WARN if !quiet + return nil + end + end + images = images[platform] + + if region + # We won't fuss about the region argument if this isn't a cloud that + # has regions, just quietly don't bother. + if images.is_a?(Hash) + if images[region] + images = images[region] + else + if fail_hard + raise MuError, "No base image for platform #{platform} in cloud #{cloud} region #{region} found" + else + MU.log "No base image for platform #{platform} in cloud #{cloud} region #{region} found", MU::WARN if !quiet + return nil + end + end + end + end + else + if region + images.each_pair { |p, regions| + # Filter to match our requested region, but for all the platforms, + # since we didn't specify one. + if regions.is_a?(Hash) + regions.delete_if { |r| r != region } + end + } + end + end + + images + end # A list of supported cloud resource types as Mu classes def self.resource_types; @@ -429,6 +646,7 @@ def self.resource_types; # @param type [String]: A string that looks like our short or full class name or singular or plural configuration names. # @return [Array]: Class name (Symbol), singular config name (String), plural config name (String), full class name (Object) def self.getResourceNames(type) + return [nil, nil, nil, nil, {}] if !type @@resource_types.each_pair { |name, cloudclass| if name == type.to_sym or cloudclass[:cfg_name] == type or @@ -462,10 +680,28 @@ def self.handleNetSSHExceptions @@supportedCloudList = ['AWS', 'CloudFormation', 'Google', 'Azure'] # List of known/supported Cloud providers + # @return [Array] def self.supportedClouds @@supportedCloudList end + # List of known/supported Cloud providers for which we have at least one + # set of credentials configured. + # @return [Array] + def self.availableClouds + available = [] + MU::Cloud.supportedClouds.each { |cloud| + begin + cloudbase = Object.const_get("MU").const_get("Cloud").const_get(cloud) + next if cloudbase.listCredentials.nil? or cloudbase.listCredentials.empty? + available << cloud + rescue NameError + end + } + + available + end + # Load the container class for each cloud we know about, and inject autoload # code for each of its supported resource type classes. failed = [] @@ -495,7 +731,7 @@ def self.userdata_mutex # @param template_variables [Hash]: A list of variable substitutions to pass as globals to the ERB parser when loading the userdata script. # @param custom_append [String]: Arbitrary extra code to append to our default userdata behavior. # @return [String] - def self.fetchUserdata(platform: "linux", template_variables: {}, custom_append: nil, cloud: "aws", scrub_mu_isms: false) + def self.fetchUserdata(platform: "linux", template_variables: {}, custom_append: nil, cloud: "AWS", scrub_mu_isms: false, credentials: nil) return nil if platform.nil? or platform.empty? userdata_mutex.synchronize { script = "" @@ -503,10 +739,16 @@ def self.fetchUserdata(platform: "linux", template_variables: {}, custom_append: if template_variables.nil? or !template_variables.is_a?(Hash) raise MuError, "My second argument should be a hash of variables to pass into ERB templates" end + template_variables["credentials"] ||= credentials $mu = OpenStruct.new(template_variables) - userdata_dir = File.expand_path(MU.myRoot+"/modules/mu/clouds/#{cloud}/userdata") - platform = "linux" if %w{centos centos6 centos7 ubuntu ubuntu14 rhel rhel7 rhel71 amazon}.include? platform - platform = "windows" if %w{win2k12r2 win2k12 win2k8 win2k8r2 win2k16}.include? platform + userdata_dir = File.expand_path(MU.myRoot+"/modules/mu/clouds/#{cloud.downcase}/userdata") + + platform = if %w{win2k12r2 win2k12 win2k8 win2k8r2 win2k16 windows win2k19}.include?(platform) + "windows" + else + "linux" + end + erbfile = "#{userdata_dir}/#{platform}.erb" if !File.exist?(erbfile) MU.log "No such userdata template '#{erbfile}'", MU::WARN, details: caller @@ -590,12 +832,12 @@ def self.loadCloudType(cloud, type) } @@resource_types[type.to_sym][:instance].each { |instance_method| if !myclass.public_instance_methods.include?(instance_method) - raise MuError, "MU::Cloud::#{cloud}::#{type} has not implemented required instance method #{instance_method}" + raise MuCloudResourceNotImplemented, "MU::Cloud::#{cloud}::#{type} has not implemented required instance method #{instance_method}" end } cloudclass.required_instance_methods.each { |instance_method| if !myclass.public_instance_methods.include?(instance_method) - raise MuError, "MU::Cloud::#{cloud}::#{type} has not implemented required instance method #{instance_method}" + MU.log "MU::Cloud::#{cloud}::#{type} has not implemented required instance method #{instance_method}, will declare as attr_accessor", MU::DEBUG end } @@ -603,7 +845,7 @@ def self.loadCloudType(cloud, type) return myclass rescue NameError => e @cloud_class_cache[cloud][type] = nil - raise MuError, "The '#{type}' resource is not supported in cloud #{cloud} (tried MU::#{cloud}::#{type})", e.backtrace + raise MuCloudResourceNotImplemented, "The '#{type}' resource is not supported in cloud #{cloud} (tried MU::#{cloud}::#{type})", e.backtrace end end @@ -624,20 +866,9 @@ def self.const_missing(symbol) @@resource_types.each_pair { |name, attrs| Object.const_get("MU").const_get("Cloud").const_get(name).class_eval { - attr_reader :cloud - attr_reader :environment attr_reader :cloudclass attr_reader :cloudobj - attr_reader :deploy_id - attr_reader :mu_name - attr_reader :cloud_id - attr_reader :credentials - attr_reader :url - attr_reader :config - attr_reader :deploydata attr_reader :destroyed - attr_reader :cfm_template - attr_reader :cfm_name attr_reader :delayed_save def self.shortname @@ -668,11 +899,6 @@ def self.deps_wait_on_my_creation MU::Cloud.resource_types[shortname.to_sym][:deps_wait_on_my_creation] end - def groomer - return @cloudobj.groomer if !@cloudobj.nil? - nil - end - # Print something palatable when we're called in a string context. def to_s fullname = "#{self.class.shortname}" @@ -688,86 +914,232 @@ def to_s return fullname end + # Set our +deploy+ and +deploy_id+ attributes, optionally doing so even + # if they have already been set. + # + # @param mommacat [MU::MommaCat]: The deploy to which we're being told we belong + # @param force [Boolean]: Set even if we already have a deploy object + # @return [String]: Our new +deploy_id+ + def intoDeploy(mommacat, force: false) + if force or (!@deploy) + MU.log "Inserting #{self} (#{self.object_id}) into #{mommacat.deploy_id}", MU::DEBUG + @deploy = mommacat + @deploy_id = @deploy.deploy_id + @cloudobj.intoDeploy(mommacat, force: force) if @cloudobj + end + @deploy_id + end # @param mommacat [MU::MommaCat]: The deployment containing this cloud resource # @param mu_name [String]: Optional- specify the full Mu resource name of an existing resource to load, instead of creating a new one # @param cloud_id [String]: Optional- specify the cloud provider's identifier for an existing resource to load, instead of creating a new one # @param kitten_cfg [Hash]: The parse configuration for this object from {MU::Config} - def initialize(mommacat: nil, - mu_name: nil, - cloud_id: nil, - credentials: nil, - kitten_cfg: nil, - delayed_save: false) - raise MuError, "Cannot invoke Cloud objects without a configuration" if kitten_cfg.nil? - @live = true - @deploy = mommacat - @config = kitten_cfg - @delayed_save = delayed_save - @cloud_id = cloud_id - @credentials = credentials - @credentials ||= kitten_cfg['credentials'] + def initialize(**args) + raise MuError, "Cannot invoke Cloud objects without a configuration" if args[:kitten_cfg].nil? + + # We are a parent wrapper object. Initialize our child object and + # housekeeping bits accordingly. + if self.class.name.match(/^MU::Cloud::([^:]+)$/) + @live = true + @delayed_save = args[:delayed_save] + @method_semaphore = Mutex.new + @method_locks = {} + if args[:mommacat] + MU.log "Initializing an instance of #{self.class.name} in #{args[:mommacat].deploy_id} #{mu_name}", MU::DEBUG, details: args[:kitten_cfg] + elsif args[:mu_name].nil? + raise MuError, "Can't instantiate a MU::Cloud object with a live deploy or giving us a mu_name" + else + MU.log "Initializing a detached #{self.class.name} named #{args[:mu_name]}", MU::DEBUG, details: args[:kitten_cfg] + end - if !@deploy.nil? - @deploy_id = @deploy.deploy_id - MU.log "Initializing an instance of #{self.class.name} in #{@deploy_id} #{mu_name}", MU::DEBUG, details: kitten_cfg - elsif mu_name.nil? - raise MuError, "Can't instantiate a MU::Cloud object with a live deploy or giving us a mu_name" + my_cloud = args[:kitten_cfg]['cloud'].to_s || MU::Config.defaultCloud + if my_cloud.nil? or !MU::Cloud.supportedClouds.include?(my_cloud) + raise MuError, "Can't instantiate a MU::Cloud object without a valid cloud (saw '#{my_cloud}')" + end + + @cloudclass = MU::Cloud.loadCloudType(my_cloud, self.class.shortname) + @cloudparentclass = Object.const_get("MU").const_get("Cloud").const_get(my_cloud) + @cloudobj = @cloudclass.new( + mommacat: args[:mommacat], + kitten_cfg: args[:kitten_cfg], + cloud_id: args[:cloud_id], + mu_name: args[:mu_name] + ) + raise MuError, "Unknown error instantiating #{self}" if @cloudobj.nil? +# These should actually call the method live instead of caching a static value + PUBLIC_ATTRS.each { |a| + instance_variable_set(("@"+a.to_s).to_sym, @cloudobj.send(a)) + } + @deploy ||= args[:mommacat] + @deploy_id ||= @deploy.deploy_id if @deploy + + # Register with the containing deployment + if !@deploy.nil? and !@cloudobj.mu_name.nil? and + !@cloudobj.mu_name.empty? and !args[:delay_descriptor_load] + describe # XXX is this actually safe here? + @deploy.addKitten(self.class.cfg_name, @config['name'], self) + elsif !@deploy.nil? and @cloudobj.mu_name.nil? + MU.log "#{self} in #{@deploy.deploy_id} didn't generate a mu_name after being loaded/initialized, dependencies on this resource will probably be confused!", MU::ERR, details: [caller, args.keys] + end + + + # We are actually a child object invoking this via super() from its + # own initialize(), so initialize all the attributes and instance + # variables we know to be universal. else - MU.log "Initializing an independent instance of #{self.class.name} named #{mu_name}", MU::DEBUG, details: kitten_cfg - end - if !kitten_cfg.has_key?("cloud") - kitten_cfg['cloud'] = MU::Config.defaultCloud - end - @cloud = kitten_cfg['cloud'] - @cloudclass = MU::Cloud.loadCloudType(@cloud, self.class.shortname) - @environment = kitten_cfg['environment'] - @method_semaphore = Mutex.new - @method_locks = {} -# XXX require subclass to provide attr_readers of @config and @deploy - @cloudobj = @cloudclass.new(mommacat: mommacat, kitten_cfg: kitten_cfg, cloud_id: cloud_id, mu_name: mu_name) + # Declare the attributes that everyone should have + class << self + PUBLIC_ATTRS.each { |a| + attr_reader a + } + end - raise MuError, "Unknown error instantiating #{self}" if @cloudobj.nil? +# XXX this butchers ::Id and ::Ref objects that might be used by dependencies() to good effect, but we also can't expect our implementations to cope with knowing when a .to_s has to be appended to things at random + @config = MU::Config.manxify(args[:kitten_cfg]) || MU::Config.manxify(args[:config]) -# If we just loaded an existing object, go ahead and prepopulate the -# describe() cache - if !cloud_id.nil? or !mu_name.nil? - @cloudobj.describe(cloud_id: cloud_id) - @cloud_id ||= @cloudobj.cloud_id - end + if !@config + MU.log "Missing config arguments in setInstanceVariables, can't initialize a cloud object without it", MU::ERR, details: args.keys + raise MuError, "Missing config arguments in setInstanceVariables" + end - @deploydata = @cloudobj.deploydata - @config = @cloudobj.config + @deploy = args[:mommacat] || args[:deploy] -# If we're going to be integrated into AD or otherwise need a short -# hostname, generate it now. - if self.class.shortname == "Server" and (@cloudobj.windows? or @config['active_directory']) and @cloudobj.mu_windows_name.nil? - if !@deploydata.nil? and !@deploydata['mu_windows_name'].nil? - @cloudobj.mu_windows_name = @deploydata['mu_windows_name'] - else - # Use the same random differentiator as the "real" name if we're - # from a ServerPool. Helpful for admin sanity. - unq = @cloudobj.mu_name.sub(/^.*?-(...)$/, '\1') - if @config['basis'] and !unq.nil? and !unq.empty? - @cloudobj.mu_windows_name = @deploy.getResourceName(@config['name'], max_length: 15, need_unique_string: true, use_unique_string: unq, reuse_unique_string: true) - else - @cloudobj.mu_windows_name = @deploy.getResourceName(@config['name'], max_length: 15, need_unique_string: true) + @credentials = args[:credentials] + @credentials ||= @config['credentials'] + + @cloud = @config['cloud'] + if !@cloud + if self.class.name.match(/^MU::Cloud::([^:]+)(?:::.+|$)/) + cloudclass_name = Regexp.last_match[1] + if MU::Cloud.supportedClouds.include?(cloudclass_name) + @cloud = cloudclass_name + end end end - end + if !@cloud + raise MuError, "Failed to determine what cloud #{self} should be in!" + end + + @environment = @config['environment'] + if @deploy + @deploy_id = @deploy.deploy_id + @appname = @deploy.appname + end + + @cloudclass = MU::Cloud.loadCloudType(@cloud, self.class.shortname) + @cloudparentclass = Object.const_get("MU").const_get("Cloud").const_get(@cloud) + + # A pre-existing object, you say? + if args[:cloud_id] + +# TODO implement ::Id for every cloud... and they should know how to get from +# cloud_desc to a fully-resolved ::Id object, not just the short string + + @cloud_id = args[:cloud_id] + describe(cloud_id: @cloud_id) + @habitat_id = habitat_id # effectively, cache this + + # If we can build us an ::Id object for @cloud_id instead of a + # string, do so. + begin + idclass = Object.const_get("MU").const_get("Cloud").const_get(@cloud).const_get("Id") + long_id = if @deploydata and @deploydata[idclass.idattr.to_s] + @deploydata[idclass.idattr.to_s] + elsif self.respond_to?(idclass.idattr) + self.send(idclass.idattr) + end + + @cloud_id = idclass.new(long_id) if !long_id.nil? and !long_id.empty? +# 1 see if we have the value on the object directly or in deploy data +# 2 set an attr_reader with the value +# 3 rewrite our @cloud_id attribute with a ::Id object + rescue NameError, MU::Cloud::MuCloudResourceNotImplemented + end + + end + + # Use pre-existing mu_name (we're probably loading an extant deploy) + # if available + if args[:mu_name] + @mu_name = args[:mu_name].dup + # If scrub_mu_isms is set, our mu_name is always just the bare name + # field of the resource. + elsif @config['scrub_mu_isms'] + @mu_name = @config['name'].dup +# XXX feck it insert an inheritable method right here? Set a default? How should resource implementations determine whether they're instantiating a new object? + end + + @tags = {} + if !@config['scrub_mu_isms'] + @tags = @deploy ? @deploy.listStandardTags : MU::MommaCat.listStandardTags + end + if @config['tags'] + @config['tags'].each { |tag| + @tags[tag['key']] = tag['value'] + } + end - # Register us with our parent deploy so that we can be found by our - # littermates if needed. - if !@deploy.nil? and !@cloudobj.mu_name.nil? and !@cloudobj.mu_name.empty? - describe # XXX is this actually safe here? - @deploy.addKitten(self.class.cfg_name, @config['name'], self) - elsif !@deploy.nil? - MU.log "#{self} didn't generate a mu_name after being loaded/initialized, dependencies on this resource will probably be confused!", MU::ERR + if @cloudparentclass.respond_to?(:resourceInitHook) + @cloudparentclass.resourceInitHook(self, @deploy) + end + + # Add cloud-specific instance methods for our resource objects to + # inherit. + if @cloudparentclass.const_defined?(:AdditionalResourceMethods) + self.extend @cloudparentclass.const_get(:AdditionalResourceMethods) + end + + if ["Server", "ServerPool"].include?(self.class.shortname) and @deploy + @mu_name ||= @deploy.getResourceName(@config['name'], need_unique_string: @config.has_key?("basis")) + if self.class.shortname == "Server" + @groomer = MU::Groomer.new(self) + end + + @groomclass = MU::Groomer.loadGroomer(@config["groomer"]) + + if windows? or @config['active_directory'] and !@mu_windows_name + if !@deploydata.nil? and !@deploydata['mu_windows_name'].nil? + @mu_windows_name = @deploydata['mu_windows_name'] + else + # Use the same random differentiator as the "real" name if we're + # from a ServerPool. Helpful for admin sanity. + unq = @mu_name.sub(/^.*?-(...)$/, '\1') + if @config['basis'] and !unq.nil? and !unq.empty? + @mu_windows_name = @deploy.getResourceName(@config['name'], max_length: 15, need_unique_string: true, use_unique_string: unq, reuse_unique_string: true) + else + @mu_windows_name = @deploy.getResourceName(@config['name'], max_length: 15, need_unique_string: true) + end + end + end + class << self + attr_reader :groomer + attr_reader :groomerclass + attr_accessor :mu_windows_name # XXX might be ok as reader now + end + end end end + def cloud + if @cloud + @cloud + elsif @config and @config['cloud'] + @config['cloud'] + elsif self.class.name.match(/^MU::Cloud::([^:]+)::.+/) + cloudclass_name = Regexp.last_match[1] + if MU::Cloud.supportedClouds.include?(cloudclass_name) + cloudclass_name + else + nil + end + else + nil + end + end + + # Remove all metadata and cloud resources associated with this object def destroy if !@cloudobj.nil? and !@cloudobj.groomer.nil? @@ -796,23 +1168,99 @@ def notify end end end + + # Return the cloud object's idea of where it lives (project, account, + # etc) in the form of an identifier. If not applicable for this object, + # we expect to return +nil+. + # @return [String,nil] + def habitat(nolookup: true) + return nil if ["folder", "habitat"].include?(self.class.cfg_name) + if @cloudobj + @cloudparentclass.habitat(@cloudobj, nolookup: nolookup, deploy: @deploy) + else + @cloudparentclass.habitat(self, nolookup: nolookup, deploy: @deploy) + end + end + + def habitat_id(nolookup: false) + @habitat_id ||= habitat(nolookup: nolookup) + @habitat_id + end + + # We're fundamentally a wrapper class, so go ahead and reroute requests + # that are meant for our wrapped object. + def method_missing(method_sym, *arguments) + if @cloudobj + MU.log "INVOKING #{method_sym.to_s} FROM PARENT CLOUD OBJECT #{self}", MU::DEBUG, details: arguments + @cloudobj.method(method_sym).call(*arguments) + else + raise NoMethodError, "No such instance method #{method_sym.to_s} available on #{self.class.name}" + end + end + + # Merge the passed hash into the existing configuration hash of this + # cloud object. Currently this is only used by the {MU::Adoption} + # module. I don't love exposing this to the whole internal API, but I'm + # probably overthinking that. + # @param newcfg [Hash] + def config!(newcfg) + @config.merge!(newcfg) + end - def cloud_desc() + def cloud_desc(use_cache: true) describe + if !@cloudobj.nil? - @cloud_desc_cache ||= @cloudobj.cloud_desc - @url = @cloudobj.url if @cloudobj.respond_to?(:url) + if @cloudobj.class.instance_methods(false).include?(:cloud_desc) + @cloud_desc_cache ||= @cloudobj.cloud_desc + end end - if !@config.nil? and !@cloud_id.nil? and @cloud_desc_cache.nil? + if !@config.nil? and !@cloud_id.nil? and (!use_cache or @cloud_desc_cache.nil?) # The find() method should be returning a Hash with the cloud_id # as a key and a cloud platform descriptor as the value. begin + args = { + :region => @config['region'], + :cloud => @config['cloud'], + :cloud_id => @cloud_id, + :credentials => @credentials, + :project => habitat_id, # XXX this belongs in our required_instance_methods hack + :flags => @config + } + @cloudparentclass.required_instance_methods.each { |m| +# if respond_to?(m) +# args[m] = method(m).call +# else + args[m] = instance_variable_get(("@"+m.to_s).to_sym) +# end + } - matches = self.class.find(region: @config['region'], cloud_id: @cloud_id, flags: @config, credentials: @credentials) - if !matches.nil? and matches.is_a?(Hash) and matches.has_key?(@cloud_id) - @cloud_desc_cache = matches[@cloud_id] - else - MU.log "Failed to find a live #{self.class.shortname} with identifier #{@cloud_id} in #{@credentials}/#{@config['region']}, which has a record in deploy #{@deploy.deploy_id}", MU::WARN, details: caller + matches = self.class.find(args) + if !matches.nil? and matches.is_a?(Hash) +# XXX or if the hash is keyed with an ::Id element, oh boy +# puts matches[@cloud_id][:self_link] +# puts matches[@cloud_id][:url] +# if matches[@cloud_id][:self_link] +# @url ||= matches[@cloud_id][:self_link] +# elsif matches[@cloud_id][:url] +# @url ||= matches[@cloud_id][:url] +# elsif matches[@cloud_id][:arn] +# @arn ||= matches[@cloud_id][:arn] +# end + if matches[@cloud_id] + @cloud_desc_cache = matches[@cloud_id] + else + matches.each_pair { |k, v| # flatten out ::Id objects just in case + if @cloud_id.to_s == k.to_s + @cloud_desc_cache = v + break + end + } + end + end + + if !@cloud_desc_cache + MU.log "cloud_desc via #{self.class.name}.find() failed to locate a live object.\nWas called by #{caller[0]}", MU::WARN, details: args end rescue Exception => e MU.log "Got #{e.inspect} trying to find cloud handle for #{self.class.shortname} #{@mu_name} (#{@cloud_id})", MU::WARN @@ -858,19 +1306,6 @@ def describe(cloud_id: nil, update_cache: false) end if @deploydata.has_key?('cloud_id') @cloud_id ||= @deploydata['cloud_id'] - else - # XXX temp hack to catch old Amazon-style identifiers. Remove this - # before supporting any other cloud layers, otherwise name - # collision is possible. - ["group_id", "instance_id", "awsname", "identifier", "vpc_id", "id"].each { |identifier| - if @deploydata.has_key?(identifier) - @cloud_id ||= @deploydata[identifier] - if @mu_name.nil? and (identifier == "awsname" or identifier == "identifier" or identifier == "group_id") - @mu_name = @deploydata[identifier] - end - break - end - } end end @@ -886,7 +1321,7 @@ def describe(cloud_id: nil, update_cache: false) # resources in this deployment), as well as for certain config stanzas # which can refer to external resources (@vpc, @loadbalancers, # @add_firewall_rules) - def dependencies(use_cache: false) + def dependencies(use_cache: false, debug: false) @dependencies = {} if @dependencies.nil? @loadbalancers = [] if @loadbalancers.nil? if @config.nil? @@ -897,6 +1332,8 @@ def dependencies(use_cache: false) end @config['dependencies'] = [] if @config['dependencies'].nil? + loglevel = debug ? MU::NOTICE : MU::DEBUG + # First, general dependencies. These should all be fellow members of # the current deployment. @config['dependencies'].each { |dep| @@ -904,7 +1341,7 @@ def dependencies(use_cache: false) next if @dependencies[dep['type']].has_key?(dep['name']) handle = @deploy.findLitterMate(type: dep['type'], name: dep['name']) if !@deploy.nil? if !handle.nil? - MU.log "Loaded dependency for #{self}: #{dep['name']} => #{handle}", MU::DEBUG + MU.log "Loaded dependency for #{self}: #{dep['name']} => #{handle}", loglevel @dependencies[dep['type']][dep['name']] = handle else # XXX yell under circumstances where we should expect to have @@ -914,62 +1351,102 @@ def dependencies(use_cache: false) # Special dependencies: my containing VPC if self.class.can_live_in_vpc and !@config['vpc'].nil? - MU.log "Loading VPC for #{self}", MU::DEBUG, details: @config['vpc'] - if !@config['vpc']["vpc_name"].nil? and @deploy - sib_by_name = @deploy.findLitterMate(name: @config['vpc']['vpc_name'], type: "vpcs", return_all: true) + @config['vpc']["id"] ||= @config['vpc']["vpc_id"] # old deploys + @config['vpc']["name"] ||= @config['vpc']["vpc_name"] # old deploys + # If something hash-ified a MU::Config::Ref here, fix it + if !@config['vpc']["id"].nil? and @config['vpc']["id"].is_a?(Hash) + @config['vpc']["id"] = MU::Config::Ref.new(@config['vpc']["id"]) + end + if !@config['vpc']["id"].nil? and @config['vpc']["id"].is_a?(MU::Config::Ref) and !@config['vpc']["id"].kitten.nil? + @vpc = @config['vpc']["id"].kitten + elsif !@config['vpc']["name"].nil? and @deploy + MU.log "Attempting findLitterMate on VPC for #{self}", loglevel, details: @config['vpc'] + + sib_by_name = @deploy.findLitterMate(name: @config['vpc']['name'], type: "vpcs", return_all: true, habitat: @config['vpc']['project'], debug: debug) if sib_by_name.is_a?(Array) if sib_by_name.size == 1 @vpc = matches.first + MU.log "Single VPC match for #{self}", loglevel, details: @vpc.to_s else # XXX ok but this is the wrong place for this really the config parser needs to sort this out somehow # we got multiple matches, try to pick one by preferred subnet # behavior + MU.log "Sorting a bunch of VPC matches for #{self}", loglevel, details: sib_by_name.map { |s| s.to_s }.join(", ") sib_by_name.each { |sibling| all_private = sibling.subnets.map { |s| s.private? }.all?(true) all_public = sibling.subnets.map { |s| s.private? }.all?(false) + names = sibling.subnets.map { |s| s.name } + ids = sibling.subnets.map { |s| s.cloud_id } if all_private and ["private", "all_private"].include?(@config['vpc']['subnet_pref']) @vpc = sibling break elsif all_public and ["public", "all_public"].include?(@config['vpc']['subnet_pref']) @vpc = sibling break - else - MU.log "Got multiple matching VPCs for #{@mu_name}, so I'm arbitrarily choosing #{sibling.mu_name}" + elsif @config['vpc']['subnet_name'] and + names.include?(@config['vpc']['subnet_name']) +puts "CHOOSING #{@vpc.to_s} 'cause it has #{@config['vpc']['subnet_name']}" + @vpc = sibling + break + elsif @config['vpc']['subnet_id'] and + ids.include?(@config['vpc']['subnet_id']) @vpc = sibling break end } + if !@vpc + sibling = sib_by_name.sample + MU.log "Got multiple matching VPCs for #{self.class.cfg_name} #{@mu_name}, so I'm arbitrarily choosing #{sibling.mu_name}", MU::WARN, details: @config['vpc'] + @vpc = sibling + end end else @vpc = sib_by_name + MU.log "Found exact VPC match for #{self}", loglevel, details: sib_by_name.to_s end + else + MU.log "No shortcuts available to fetch VPC for #{self}", loglevel, details: @config['vpc'] end - if !@vpc and !@config['vpc']["vpc_name"].nil? and + if !@vpc and !@config['vpc']["name"].nil? and @dependencies.has_key?("vpc") and - @dependencies["vpc"].has_key?(@config['vpc']["vpc_name"]) - @vpc = @dependencies["vpc"][@config['vpc']["vpc_name"]] + @dependencies["vpc"].has_key?(@config['vpc']["name"]) + MU.log "Grabbing VPC I see in @dependencies['vpc']['#{@config['vpc']["name"]}'] for #{self}", loglevel, details: @config['vpc'] + @vpc = @dependencies["vpc"][@config['vpc']["name"]] elsif !@vpc tag_key, tag_value = @config['vpc']['tag'].split(/=/, 2) if !@config['vpc']['tag'].nil? - if !@config['vpc'].has_key?("vpc_id") and + if !@config['vpc'].has_key?("id") and !@config['vpc'].has_key?("deploy_id") and !@deploy.nil? @config['vpc']["deploy_id"] = @deploy.deploy_id end + MU.log "Doing findStray for VPC for #{self}", loglevel, details: @config['vpc'] vpcs = MU::MommaCat.findStray( @config['cloud'], "vpc", deploy_id: @config['vpc']["deploy_id"], - cloud_id: @config['vpc']["vpc_id"], - name: @config['vpc']["vpc_name"], + cloud_id: @config['vpc']["id"], + name: @config['vpc']["name"], tag_key: tag_key, tag_value: tag_value, + habitats: [@project_id], region: @config['vpc']["region"], calling_deploy: @deploy, - dummy_ok: true + credentials: @credentials, + dummy_ok: true, + debug: debug ) @vpc = vpcs.first if !vpcs.nil? and vpcs.size > 0 end - if !@vpc.nil? and ( + if @vpc and @vpc.config and @vpc.config['bastion'] and + @vpc.config['bastion'].to_h['name'] != @config['name'] + refhash = @vpc.config['bastion'].to_h + refhash['deploy_id'] ||= @vpc.deploy.deploy_id + natref = MU::Config::Ref.get(refhash) + if natref and natref.kitten(@vpc.deploy) + @nat = natref.kitten(@vpc.deploy) + end + end + if @nat.nil? and !@vpc.nil? and ( @config['vpc'].has_key?("nat_host_id") or @config['vpc'].has_key?("nat_host_tag") or @config['vpc'].has_key?("nat_host_ip") or @@ -1008,6 +1485,22 @@ def dependencies(use_cache: false) @vpc = self end + # Google accounts usually have a useful default VPC we can use + if @vpc.nil? and @project_id and @cloud == "Google" and + self.class.can_live_in_vpc + MU.log "Seeing about default VPC for #{self.to_s}", MU::NOTICE + vpcs = MU::MommaCat.findStray( + "Google", + "vpc", + cloud_id: "default", + habitats: [@project_id], + credentials: @credentials, + dummy_ok: true, + debug: debug + ) + @vpc = vpcs.first if !vpcs.nil? and vpcs.size > 0 + end + # Special dependencies: LoadBalancers I've asked to attach to an # instance. if @config.has_key?("loadbalancers") @@ -1046,17 +1539,46 @@ def self.quality MU::Cloud::ALPHA end + # Return a list of "container" artifacts, by class, that apply to this + # resource type in a cloud provider. This is so methods that call find + # know whether to call +find+ with identifiers for parent resources. + # This is similar in purpose to the +isGlobal?+ resource class method, + # which tells our search functions whether or not a resource scopes to + # a region. In almost all cases this is one-entry list consisting of + # +:Habitat+. Notable exceptions include most implementations of + # +Habitat+, which either reside inside a +:Folder+ or nothing at all; + # whereas a +:Folder+ tends to not have any containing parent. Very few + # resource implementations will need to override this. + # A +nil+ entry in this list is interpreted as "this resource can be + # global." + # @return [Array] + def self.canLiveIn + if self.shortname == "Folder" + [nil, :Folder] + elsif self.shortname == "Habitat" + [:Folder] + else + [:Habitat] + end + end + def self.find(*flags) allfound = {} - MU::Cloud.supportedClouds.each { |cloud| + MU::Cloud.availableClouds.each { |cloud| begin args = flags.first + next if args[:cloud] and args[:cloud] != cloud # skip this cloud if we have a region argument that makes no # sense there cloudbase = Object.const_get("MU").const_get("Cloud").const_get(cloud) + next if cloudbase.listCredentials.nil? or cloudbase.listCredentials.empty? or cloudbase.credConfig(args[:credentials]).nil? if args[:region] and cloudbase.respond_to?(:listRegions) - next if !cloudbase.listRegions(credentials: args[:credentials]).include?(args[:region]) + if !cloudbase.listRegions(credentials: args[:credentials]) + MU.log "Failed to get region list for credentials #{args[:credentials]} in cloud #{cloud}", MU::ERR, details: caller + else + next if !cloudbase.listRegions(credentials: args[:credentials]).include?(args[:region]) + end end begin cloudclass = MU::Cloud.loadCloudType(cloud, shortname) @@ -1094,9 +1616,9 @@ def self.createRecordsFromConfig(*flags) end end - if shortname == "Server" + if shortname == "Server" or shortname == "ServerPool" def windows? - return true if %w{win2k16 win2k12r2 win2k12 win2k8 win2k8r2 windows}.include?(@config['platform']) + return true if %w{win2k16 win2k12r2 win2k12 win2k8 win2k8r2 win2k19 windows}.include?(@config['platform']) begin return true if cloud_desc.respond_to?(:platform) and cloud_desc.platform == "Windows" # XXX ^ that's AWS-speak, doesn't cover GCP or anything else; maybe we should require cloud layers to implement this so we can just call @cloudobj.windows? @@ -1330,7 +1852,9 @@ def initialSSHTasks(ssh) if !output.nil? and !output.empty? raise MU::Cloud::BootstrapTempFail, "Linux package manager is still doing something, need to wait (#{output})" end - if !@config['skipinitialupdates'] + if !@config['skipinitialupdates'] and + !@config['scrub_mu_isms'] and + !@config['userdata_script'] output = ssh.exec!(lnx_updates_check) if !output.nil? and output.match(/userdata still running/) raise MU::Cloud::BootstrapTempFail, "Waiting for initial userdata system updates to complete" @@ -1408,6 +1932,8 @@ def getSSHSession(max_retries = 12, retry_interval = 30) session = nil retries = 0 + vpc_class = Object.const_get("MU").const_get("Cloud").const_get(@cloud).const_get("VPC") + # XXX WHY is this a thing Thread.handle_interrupt(Errno::ECONNREFUSED => :never) { } @@ -1431,6 +1957,7 @@ def getSSHSession(max_retries = 12, retry_interval = 30) :proxy => proxy ) else + MU.log "Attempting SSH to #{canonical_ip} (#{@mu_name}) as #{ssh_user} with key #{ssh_keydir}/#{@deploy.ssh_key_name}" if retries == 0 session = Net::SSH.start( canonical_ip, @@ -1464,16 +1991,21 @@ def getSSHSession(max_retries = 12, retry_interval = 30) if retries < max_retries retries = retries + 1 - msg = "ssh #{ssh_user}@#{@config['mu_name']}: #{e.message}, waiting #{retry_interval}s (attempt #{retries}/#{max_retries})", MU::WARN + msg = "ssh #{ssh_user}@#{@mu_name}: #{e.message}, waiting #{retry_interval}s (attempt #{retries}/#{max_retries})" if retries == 1 or (retries/max_retries <= 0.5 and (retries % 3) == 0) MU.log msg, MU::NOTICE + if !vpc_class.haveRouteToInstance?(cloud_desc, credentials: @credentials) and + canonical_ip.match(/(^127\.)|(^192\.168\.)|(^10\.)|(^172\.1[6-9]\.)|(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^::1$)|(^[fF][cCdD])/) and + !nat_ssh_host + MU.log "Node #{@mu_name} at #{canonical_ip} looks like it's in a private address space, and I don't appear to have a direct route to it. It may not be possible to connect with this routing!", MU::WARN + end elsif retries/max_retries > 0.5 MU.log msg, MU::WARN, details: e.inspect end sleep retry_interval retry else - raise MuError, "#{@config['mu_name']}: #{e.inspect} trying to connect with SSH, max_retries exceeded", e.backtrace + raise MuError, "#{@mu_name}: #{e.inspect} trying to connect with SSH, max_retries exceeded", e.backtrace end end return session @@ -1501,6 +2033,17 @@ def self.cleanup(*flags) MU::MommaCat.unlockAll end + # A hook that is always called just before each instance method is + # invoked, so that we can ensure that repetitive setup tasks (like + # resolving +:resource_group+ for Azure resources) have always been + # done. + def resourceInitHook + @cloud ||= cloud + if @cloudparentclass.respond_to?(:resourceInitHook) + @cloudparentclass.resourceInitHook(@cloudobj, @deploy) + end + end + # Wrap the instance methods that this cloud resource type has to # implement. MU::Cloud.resource_types[name.to_sym][:instance].each { |method| @@ -1538,15 +2081,19 @@ def self.cleanup(*flags) if (method == :create or method == :groom or method == :postBoot) and (!@destroyed and !@cloudobj.destroyed) deploydata = @cloudobj.method(:notify).call + @deploydata ||= deploydata # XXX I don't remember why we're not just doing this from the get-go; maybe because we prefer some mangling occurring in @deploy.notify? if deploydata.nil? or !deploydata.is_a?(Hash) - MU.log "#{self} notify method did not return a Hash of deployment data", MU::WARN + MU.log "#{self} notify method did not return a Hash of deployment data, attempting to fill in with cloud descriptor #{@cloudobj.cloud_id}", MU::WARN deploydata = MU.structToHash(@cloudobj.cloud_desc) + raise MuError, "Failed to collect metadata about #{self}" if deploydata.nil? end - deploydata['cloud_id'] = @cloudobj.cloud_id if !@cloudobj.cloud_id.nil? + deploydata['cloud_id'] ||= @cloudobj.cloud_id if !@cloudobj.cloud_id.nil? deploydata['mu_name'] = @cloudobj.mu_name if !@cloudobj.mu_name.nil? + deploydata['nodename'] = @cloudobj.mu_name if !@cloudobj.mu_name.nil? + deploydata.delete("#MUOBJECT") @deploy.notify(self.class.cfg_plural, @config['name'], deploydata, triggering_node: @cloudobj, delayed_save: @delayed_save) if !@deploy.nil? elsif method == :notify - retval['cloud_id'] = @cloudobj.cloud_id if !@cloudobj.cloud_id.nil? + retval['cloud_id'] = @cloudobj.cloud_id.to_s if !@cloudobj.cloud_id.nil? retval['mu_name'] = @cloudobj.mu_name if !@cloudobj.mu_name.nil? @deploy.notify(self.class.cfg_plural, @config['name'], retval, triggering_node: @cloudobj, delayed_save: @delayed_save) if !@deploy.nil? end diff --git a/modules/mu/clouds/aws.rb b/modules/mu/clouds/aws.rb index e9c18e49b..56bcc9eb9 100644 --- a/modules/mu/clouds/aws.rb +++ b/modules/mu/clouds/aws.rb @@ -28,6 +28,24 @@ class AWS @@creds_loaded = {} + # Module used by {MU::Cloud} to insert additional instance methods into + # instantiated resources in this cloud layer. + module AdditionalResourceMethods + end + + # A hook that is always called just before any of the instance method of + # our resource implementations gets invoked, so that we can ensure that + # repetitive setup tasks (like resolving +:resource_group+ for Azure + # resources) have always been done. + # @param cloudobj [MU::Cloud] + # @param deploy [MU::MommaCat] + def self.resourceInitHook(cloudobj, deploy) + class << self + attr_reader :cloudformation_data + end + cloudobj.instance_variable_set(:@cloudformation_data, {}) + end + # Load some credentials for using the AWS API # @param name [String]: The name of the mu.yaml AWS credential set to use. If not specified, will use the default credentials, and set the global Aws.config credentials to those. # @return [Aws::Credentials] @@ -110,7 +128,7 @@ def self.loadCredentials(name = nil) else MU.log "AWS credentials vault:item #{cred_cfg["credentials"]} specified, but is missing access_key or access_secret elements", MU::WARN end - rescue MU::Groomer::Chef::MuNoSuchSecret + rescue MU::Groomer::MuNoSuchSecret MU.log "AWS credentials vault:item #{cred_cfg["credentials"]} specified, but does not exist", MU::WARN end end @@ -145,39 +163,103 @@ def self.required_instance_methods # Given an AWS region, check the API to make sure it's a valid one # @param r [String] # @return [String] - def self.validate_region(r) - MU::Cloud::AWS.ec2(region: r).describe_availability_zones.availability_zones.first.region_name + def self.validate_region(r, credentials: nil) + begin + MU::Cloud::AWS.ec2(region: r, credentials: credentials).describe_availability_zones.availability_zones.first.region_name + rescue ::Aws::EC2::Errors::UnauthorizedOperation => e + MU.log "Got '#{e.message}' trying to validate region #{r} (hosted: #{hosted?.to_s})", MU::ERR, details: loadCredentials(credentials) + raise MuError, "Got '#{e.message}' trying to validate region #{r} with credentials #{credentials ? credentials : ""} (hosted: #{hosted?.to_s})" + end + end + + # Tag a resource with all of our standard identifying tags. + # + # @param resource [String]: The cloud provider identifier of the resource to tag + # @param region [String]: The cloud provider region + # @return [void] + def self.createStandardTags(resource = nil, region: MU.curRegion, credentials: nil) + tags = [] + MU::MommaCat.listStandardTags.each_pair { |name, value| + if !value.nil? + tags << {key: name, value: value} + end + } + if MU::Cloud::CloudFormation.emitCloudFormation + return tags + end + + attempts = 0 + begin + MU::Cloud::AWS.ec2(region: region, credentials: credentials).create_tags( + resources: [resource], + tags: tags + ) + rescue Aws::EC2::Errors::ServiceError => e + MU.log "Got #{e.inspect} tagging #{resource} in #{region}, will retry", MU::WARN, details: caller.concat(tags) if attempts > 1 + if attempts < 5 + attempts = attempts + 1 + sleep 15 + retry + else + raise e + end + end + MU.log "Created standard tags for resource #{resource}", MU::DEBUG, details: caller + end + + @@myVPCObj = nil + + # If we reside in this cloud, return the VPC in which we, the Mu Master, reside. + # @return [MU::Cloud::VPC] + def self.myVPCObj + return @@myVPCObj if @@myVPCObj + return nil if !hosted? + instance = MU.myCloudDescriptor + return nil if !instance or !instance.vpc_id + vpc = MU::MommaCat.findStray("AWS", "vpc", cloud_id: instance.vpc_id, dummy_ok: true, no_deploy_search: true) + return nil if vpc.nil? or vpc.size == 0 + @@myVPCObj = vpc.first + @@myVPCObj end # If we've configured AWS as a provider, or are simply hosted in AWS, # decide what our default region is. - def self.myRegion + def self.myRegion(credentials = nil) return @@myRegion_var if @@myRegion_var if credConfig.nil? and !hosted? and !ENV['EC2_REGION'] return nil end - if $MU_CFG and $MU_CFG['aws'] $MU_CFG['aws'].each_pair { |credset, cfg| + next if credentials and credset != credentials next if !cfg['region'] - if (cfg['default'] or !@@myRegion_var) and validate_region(cfg['region']) + if (cfg['default'] or !@@myRegion_var) and validate_region(cfg['region'], credentials: credset) @@myRegion_var = cfg['region'] - break if cfg['default'] + break if cfg['default'] or credentials end } elsif ENV.has_key?("EC2_REGION") and !ENV['EC2_REGION'].empty? and - validate_region(ENV['EC2_REGION']) + validate_region(ENV['EC2_REGION']) and + ( + (ENV.has_key?("AWS_SECRET_ACCESS_KEY") and ENV.has_key?("AWS_SECRET_ACCESS_KEY") ) or + (Aws.config['access_key'] and Aws.config['access_secret']) + ) # Make sure this string is valid by way of the API @@myRegion_var = ENV['EC2_REGION'] - else + end + + if hosted? and !@@myRegion_var # hacky, but useful in a pinch (and if we're hosted in AWS) az_str = MU::Cloud::AWS.getAWSMetaData("placement/availability-zone") @@myRegion_var = az_str.sub(/[a-z]$/i, "") if az_str end + + @@myRegion_var end + # Is the region we're dealing with a GovCloud region? # @param region [String]: The region in question, defaults to the Mu Master's local region def self.isGovCloud?(region = myRegion) @@ -247,9 +329,8 @@ def self.createTag(key, value, resources = [], region: myRegion, credentials: ni # @param region [String]: The region to search. # @return [Array]: The Availability Zones in this region. def self.listAZs(region: MU.curRegion, account: nil, credentials: nil) - if $MU_CFG and (!$MU_CFG['aws'] or !account_number) - return [] - end + cfg = credConfig(credentials) + return [] if !cfg if !region.nil? and @@azs[region] return @@azs[region] end @@ -265,6 +346,18 @@ def self.listAZs(region: MU.curRegion, account: nil, credentials: nil) return @@azs[region] end + # Do cloud-specific deploy instantiation tasks, such as copying SSH keys + # around, sticking secrets in buckets, creating resource groups, etc + # @param deploy [MU::MommaCat] + def self.initDeploy(deploy) + end + + # Purge cloud-specific deploy meta-artifacts (SSH keys, resource groups, + # etc) + # @param deploy_id [MU::MommaCat] + def self.cleanDeploy(deploy_id, credentials: nil, noop: false) + end + # Plant a Mu deploy secret into a storage bucket somewhere for so our kittens can consume it # @param deploy_id [String]: The deploy for which we're writing the secret # @param value [String]: The contents of the secret @@ -346,6 +439,14 @@ def self.hosted? instance_id = open("http://169.254.169.254/latest/meta-data/instance-id").read if !instance_id.nil? and instance_id.size > 0 @@is_in_aws = true + region = getAWSMetaData("placement/availability-zone").sub(/[a-z]$/i, "") + begin + validate_region(region) + rescue MuError + @@creds_loaded.delete("#default") + @@is_in_aws = false + false + end return true end end @@ -384,6 +485,16 @@ def self.config_example sample end + # Return what we think of as a cloud object's habitat. In AWS, this means + # the +account_number+ in which it's resident. If this is not applicable, + # such as for a {Habitat} or {Folder}, returns nil. + # @param cloudobj [MU::Cloud::AWS]: The resource from which to extract the habitat id + # @return [String,nil] + def self.habitat(cloudobj, nolookup: false, deploy: nil) + cloudobj.respond_to?(:account_number) ? cloudobj.account_number : nil + end + + @@my_acct_num = nil @@my_hosted_cfg = nil @@acct_to_profile_map = {} @@ -415,8 +526,29 @@ def self.listCredentials # @param credentials [String] # @return [String] def self.adminBucketName(credentials = nil) - #XXX find a default if this particular account doesn't have a log_bucket_name configured cfg = credConfig(credentials) + return nil if !cfg + if !cfg['log_bucket_name'] + cfg['log_bucket_name'] = $MU_CFG['hostname'] + MU.log "No AWS log bucket defined for credentials #{credentials}, attempting to use default of #{cfg['log_bucket_name']}", MU::WARN + end + resp = MU::Cloud::AWS.s3(credentials: credentials).list_buckets + found = false + resp.buckets.each { |b| + if b.name == cfg['log_bucket_name'] + found = true + break + end + } + if !found + MU.log "Attempting to create log bucket #{cfg['log_bucket_name']} for credentials #{credentials}", MU::WARN + begin + resp = MU::Cloud::AWS.s3(credentials: credentials).create_bucket(bucket: cfg['log_bucket_name'], acl: "private") + rescue Aws::S3::Errors::BucketAlreadyExists => e + raise MuError, "AWS credentials #{credentials} need a log bucket, and the name #{cfg['log_bucket_name']} is unavailable. Use mu-configure to edit credentials '#{credentials}' or 'hostname'" + end + end + cfg['log_bucket_name'] end @@ -425,7 +557,8 @@ def self.adminBucketName(credentials = nil) # @param credentials [String] # @return [String] def self.adminBucketUrl(credentials = nil) - "s3://"+adminBucketName+"/" + return nil if !credConfig(credentials) + "s3://"+adminBucketName(credentials)+"/" end # Return the $MU_CFG data associated with a particular profile/name/set of @@ -439,7 +572,9 @@ def self.credConfig(name = nil, name_only: false) # on a machine hosted in AWS, *and* that machine has an IAM profile, # fake it with those credentials and hope for the best. if !$MU_CFG['aws'] or !$MU_CFG['aws'].is_a?(Hash) or $MU_CFG['aws'].size == 0 - return @@my_hosted_cfg if @@my_hosted_cfg + if @@my_hosted_cfg + return name_only ? "#default" : @@my_hosted_cfg + end if hosted? begin @@ -464,9 +599,9 @@ def self.credConfig(name = nil, name_only: false) end if name.nil? - $MU_CFG['aws'].each_pair { |name, cfg| + $MU_CFG['aws'].each_pair { |set, cfg| if cfg['default'] - return name_only ? name : cfg + return name_only ? set : cfg end } else @@ -496,7 +631,6 @@ def self.credConfig(name = nil, name_only: false) cfg['account_number'] = acct_num.to_s @@acct_to_profile_map[name.to_s] = cfg return name_only ? name.to_s : cfg - return cfg end } end @@ -1026,10 +1160,14 @@ def self.getAWSMetaData(param) # @return [void] def self.openFirewallForClients MU::Cloud.loadCloudType("AWS", :FirewallRule) - if File.exists?(Etc.getpwuid(Process.uid).dir+"/.chef/knife.rb") - ::Chef::Config.from_file(Etc.getpwuid(Process.uid).dir+"/.chef/knife.rb") + begin + if File.exist?(Etc.getpwuid(Process.uid).dir+"/.chef/knife.rb") + ::Chef::Config.from_file(Etc.getpwuid(Process.uid).dir+"/.chef/knife.rb") + end + ::Chef::Config[:environment] = MU.environment + rescue LoadError + # XXX why is Chef here end - ::Chef::Config[:environment] = MU.environment # This is the set of (TCP) ports we're opening to clients. We assume that # we can and and remove these without impacting anything a human has @@ -1218,8 +1356,6 @@ def initialize(region: MU.curRegion, api: "EC2", credentials: nil) MU.log "Initializing #{api} object with credentials #{credentials}", MU::DEBUG, details: params @api = Object.const_get("Aws::#{api}::Client").new(params) - - @api end @instance_cache = {} @@ -1239,7 +1375,7 @@ def method_missing(method_sym, *arguments) retval = @api.method(method_sym).call end return retval - rescue Aws::EC2::Errors::InternalError, Aws::EC2::Errors::RequestLimitExceeded, Aws::EC2::Errors::Unavailable, Aws::Route53::Errors::Throttling, Aws::ElasticLoadBalancing::Errors::HttpFailureException, Aws::EC2::Errors::Http503Error, Aws::AutoScaling::Errors::Http503Error, Aws::AutoScaling::Errors::InternalFailure, Aws::AutoScaling::Errors::ServiceUnavailable, Aws::Route53::Errors::ServiceUnavailable, Aws::ElasticLoadBalancing::Errors::Throttling, Aws::RDS::Errors::ClientUnavailable, Aws::Waiters::Errors::UnexpectedError, Aws::ElasticLoadBalancing::Errors::ServiceUnavailable, Aws::ElasticLoadBalancingV2::Errors::Throttling, Seahorse::Client::NetworkingError, Aws::IAM::Errors::Throttling, Aws::EFS::Errors::ThrottlingException, Aws::Pricing::Errors::ThrottlingException, Aws::APIGateway::Errors::TooManyRequestsException => e + rescue Aws::EC2::Errors::InternalError, Aws::EC2::Errors::RequestLimitExceeded, Aws::EC2::Errors::Unavailable, Aws::Route53::Errors::Throttling, Aws::ElasticLoadBalancing::Errors::HttpFailureException, Aws::EC2::Errors::Http503Error, Aws::AutoScaling::Errors::Http503Error, Aws::AutoScaling::Errors::InternalFailure, Aws::AutoScaling::Errors::ServiceUnavailable, Aws::Route53::Errors::ServiceUnavailable, Aws::ElasticLoadBalancing::Errors::Throttling, Aws::RDS::Errors::ClientUnavailable, Aws::Waiters::Errors::UnexpectedError, Aws::ElasticLoadBalancing::Errors::ServiceUnavailable, Aws::ElasticLoadBalancingV2::Errors::Throttling, Seahorse::Client::NetworkingError, Aws::IAM::Errors::Throttling, Aws::EFS::Errors::ThrottlingException, Aws::Pricing::Errors::ThrottlingException, Aws::APIGateway::Errors::TooManyRequestsException, Aws::ECS::Errors::ThrottlingException, Net::ReadTimeout, Faraday::TimeoutError => e if e.class.name == "Seahorse::Client::NetworkingError" and e.message.match(/Name or service not known/) MU.log e.inspect, MU::ERR raise e diff --git a/modules/mu/clouds/aws/alarm.rb b/modules/mu/clouds/aws/alarm.rb index 82745beb5..2e1063dad 100644 --- a/modules/mu/clouds/aws/alarm.rb +++ b/modules/mu/clouds/aws/alarm.rb @@ -17,21 +17,11 @@ class Cloud class AWS # A alarm as configured in {MU::Config::BasketofKittens::alarms} class Alarm < MU::Cloud::Alarm - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - @cloudformation_data = {} - attr_reader :cloudformation_data - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::alarms} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"]) end @@ -157,12 +147,9 @@ def self.quality end # Locate an existing alarm. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags # @return [OpenStruct]: The cloud provider's complete descriptions of matching alarm. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) - MU::Cloud::AWS::Alarm.getAlarmByName(cloud_id, region: region, credentials: credentials) + def self.find(**args) + MU::Cloud::AWS::Alarm.getAlarmByName(args[:cloud_id], region: args[:region], credentials: args[:credentials]) end # Create an alarm. @@ -270,13 +257,13 @@ def self.validateConfig(alarm, configurator) alarm["dimensions"] ||= [] if alarm["#TARGETCLASS"] == "cache_cluster" - alarm['dimensions'] << { "name" => alarm["#TARGETCLASS"], "cloud_class" => "CacheClusterId" } + alarm['dimensions'] << { "name" => alarm["#TARGETNAME"], "cloud_class" => "CacheClusterId" } alarm["namespace"] = "AWS/ElastiCache" if alarm["namespace"].nil? elsif alarm["#TARGETCLASS"] == "server" - alarm['dimensions'] << { "name" => alarm["#TARGETCLASS"], "cloud_class" => "InstanceId" } + alarm['dimensions'] << { "name" => alarm["#TARGETNAME"], "cloud_class" => "InstanceId" } alarm["namespace"] = "AWS/EC2" if alarm["namespace"].nil? elsif alarm["#TARGETCLASS"] == "database" - alarm['dimensions'] << { "name" => alarm["#TARGETCLASS"], "cloud_class" => "DBInstanceIdentifier" } + alarm['dimensions'] << { "name" => alarm["#TARGETNAME"], "cloud_class" => "DBInstanceIdentifier" } alarm["namespace"] = "AWS/RDS" if alarm["namespace"].nil? end diff --git a/modules/mu/clouds/aws/bucket.rb b/modules/mu/clouds/aws/bucket.rb index 2258f669d..57cbfd5ff 100644 --- a/modules/mu/clouds/aws/bucket.rb +++ b/modules/mu/clouds/aws/bucket.rb @@ -17,22 +17,14 @@ class Cloud class AWS # Support for AWS S3 class Bucket < MU::Cloud::Bucket - @deploy = nil - @config = nil @@region_cache = {} @@region_cache_semaphore = Mutex.new - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::logs} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"]) end @@ -41,11 +33,17 @@ def create bucket_name = @deploy.getResourceName(@config["name"], max_length: 63).downcase MU.log "Creating S3 bucket #{bucket_name}" - MU::Cloud::AWS.s3(credentials: @config['credentials'], region: @config['region']).create_bucket( + resp = MU::Cloud::AWS.s3(credentials: @config['credentials'], region: @config['region']).create_bucket( acl: @config['acl'], bucket: bucket_name ) + @cloud_id = bucket_name + is_live = MU::Cloud::AWS::Bucket.find(cloud_id: @cloud_id, region: @config['region'], credentials: @credentials).values.first + begin + is_live = MU::Cloud::AWS::Bucket.find(cloud_id: @cloud_id, region: @config['region'], credentials: @credentials).values.first + sleep 3 + end while !is_live @@region_cache_semaphore.synchronize { @@region_cache[@cloud_id] ||= @config['region'] @@ -145,6 +143,51 @@ def groom end end + # Upload a file to a bucket. + # @param url [String]: Target URL, of the form s3://bucket/folder/file + # @param acl [String]: Canned ACL permission to assign to the object we upload + # @param file [String]: Path to a local file to write to our target location. One of +file+ or +data+ must be specified. + # @param data [String]: Data to write to our target location. One of +file+ or +data+ must be specified. + def self.upload(url, acl: "private", file: nil, data: nil, credentials: nil, region: nil) + if (!file or file.empty?) and !data + raise MuError, "Must specify a file or some data to upload to bucket #{s3_url}" + end + + if file and !file.empty? + if !File.exist?(file) or !File.readable?(file) + raise MuError, "Unable to read #{file} for upload to #{url}" + else + data = File.read(file) + end + end + + url.match(/^(?:s3:\/\/)([^\/:]+?)[\/:]\/?(.+)?/) + bucket = Regexp.last_match[1] + path = Regexp.last_match[2] + if !path + if !file + raise MuError, "Unable to determine upload path from url #{url}" + end + end + + begin +puts data +puts acl +puts bucket +puts path + MU.log "Writing #{path} to S3 bucket #{bucket}" + MU::Cloud::AWS.s3(region: region, credentials: credentials).put_object( + acl: acl, + bucket: bucket, + key: path, + body: data + ) + rescue Aws::S3::Errors => e + raise MuError, "Got #{e.inspect} trying to write #{path} to #{bucket} (region: #{region}, credentials: #{credentials})" + end + + end + # Does this resource type exist as a global (cloud-wide) artifact, or # is it localized to a region/zone? # @return [Boolean] @@ -172,13 +215,18 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent if @@region_cache[bucket.name] next if @@region_cache[bucket.name] != region else - location = MU::Cloud::AWS.s3(credentials: credentials, region: region).get_bucket_location(bucket: bucket.name).location_constraint - - if location.nil? or location.empty? - @@region_cache[bucket.name] = region - else - @@region_cache[bucket.name] = location + begin + location = MU::Cloud::AWS.s3(credentials: credentials, region: region).get_bucket_location(bucket: bucket.name).location_constraint + if location.nil? or location.empty? + @@region_cache[bucket.name] = region + else + @@region_cache[bucket.name] = location + end + rescue Aws::S3::Errors::NoSuchBucket, Aws::S3::Errors::AccessDenied + # this is routine- we saw a bucket that's not our business + next end + end } @@ -219,14 +267,14 @@ def notify end # Locate an existing bucket. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching bucket. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching bucket. + def self.find(**args) found = {} - if cloud_id - found[cloud_id] = describe_bucket(cloud_id, minimal: true, credentials: credentials, region: region) + if args[:cloud_id] + begin + found[args[:cloud_id]] = describe_bucket(args[:cloud_id], minimal: true, credentials: args[:credentials], region: args[:region]) + rescue ::Aws::S3::Errors::NoSuchBucket + end end found end diff --git a/modules/mu/clouds/aws/cache_cluster.rb b/modules/mu/clouds/aws/cache_cluster.rb index a1775ab82..e56b22238 100644 --- a/modules/mu/clouds/aws/cache_cluster.rb +++ b/modules/mu/clouds/aws/cache_cluster.rb @@ -17,23 +17,11 @@ class Cloud class AWS # A cache cluster as configured in {MU::Config::BasketofKittens::cache_clusters} class CacheCluster < MU::Cloud::CacheCluster - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :cloud_id - attr_reader :config - - @cloudformation_data = {} - attr_reader :cloudformation_data - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::cache_clusters} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - # @mu_name = mu_name ? mu_name : @deploy.getResourceName(@config["name"]) + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= if @config["create_replication_group"] @deploy.getResourceName(@config["name"], max_length: 16, need_unique_string: true) @@ -51,27 +39,22 @@ def arn end # Locate an existing Cache Cluster or Cache Clusters and return an array containing matching AWS resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @param flags [Hash]: Optional flags - # @return [Array>]: The cloud provider's complete descriptions of matching Cache Clusters. - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching Cache Clusters. + def self.find(**args) map = {} - if cloud_id - cache_cluster = MU::Cloud::AWS::CacheCluster.getCacheClusterById(cloud_id, region: region) - map[cloud_id] = cache_cluster if cache_cluster + if args[:cloud_id] + cache_cluster = MU::Cloud::AWS::CacheCluster.getCacheClusterById(args[:cloud_id], region: args[:region], credentials: args[:credentials]) + map[args[:cloud_id]] = cache_cluster if cache_cluster end - if tag_value - MU::Cloud::AWS.elasticache(region: region, credentials: credentials).describe_cache_clusters.cache_clusters.each { |cc| - resp = MU::Cloud::AWS.elasticache(region: region, credentials: credentials).list_tags_for_resource( - resource_name: MU::Cloud::AWS::CacheCluster.getARN(cc.cache_cluster_id, "cluster", "elasticache", region: region, credentials: credentials) + if args[:tag_value] + MU::Cloud::AWS.elasticache(region: args[:region], credentials: args[:credentials]).describe_cache_clusters.cache_clusters.each { |cc| + resp = MU::Cloud::AWS.elasticache(region: args[:region], credentials: args[:credentials]).list_tags_for_resource( + resource_name: MU::Cloud::AWS::CacheCluster.getARN(cc.cache_cluster_id, "cluster", "elasticache", region: args[:region], credentials: args[:credentials]) ) if resp && resp.tag_list && !resp.tag_list.empty? resp.tag_list.each { |tag| - map[cc.cache_cluster_id] = cc if tag.key == tag_key and tag.value == tag_value + map[cc.cache_cluster_id] = cc if tag.key == args[:tag_key] and tag.value == args[:tag_value] } end } @@ -194,8 +177,8 @@ def create waiter.before_attempt do |attempts| MU.log "Waiting for cache replication group #{@config['identifier']} to become available", MU::NOTICE if attempts % 5 == 0 end - waiter.before_wait do |attempts, resp| - throw :success if resp.replication_groups.first.status == "available" + waiter.before_wait do |attempts, r| + throw :success if r.replication_groups.first.status == "available" throw :failure if Time.now - wait_start_time > 1800 end end @@ -234,14 +217,25 @@ def create @cloud_id = resp.replication_group_id else config_struct[:cache_cluster_id] = @config['identifier'] - config_struct[:az_mode] = @config["az_mode"] + config_struct[:az_mode] = @config["multi_az"] ? "cross-az" : "single-az" config_struct[:num_cache_nodes] = @config["node_count"] # config_struct[:replication_group_id] = @config["replication_group_id"] if @config["replication_group_id"] # config_struct[:preferred_availability_zone] = @config["preferred_availability_zone"] if @config["preferred_availability_zone"] && @config["az_mode"] == "single-az" # config_struct[:preferred_availability_zones] = @config["preferred_availability_zones"] if @config["preferred_availability_zones"] && @config["az_mode"] == "cross-az" MU.log "Creating cache cluster #{@config['identifier']}" - resp = MU::Cloud::AWS.elasticache(region: @config['region'], credentials: @config['credentials']).create_cache_cluster(config_struct).cache_cluster + begin + resp = MU::Cloud::AWS.elasticache(region: @config['region'], credentials: @config['credentials']).create_cache_cluster(config_struct).cache_cluster + rescue ::Aws::ElastiCache::Errors::InvalidParameterValue => e + if e.message.match(/security group (sg-[^\s]+)/) + bad_sg = Regexp.last_match[1] + MU.log "Removing invalid security group #{bad_sg} from Cache Cluster #{@mu_name}", MU::WARN, details: e.message + config_struct[:security_group_ids].delete(bad_sg) + retry + else + raise e + end + end wait_start_time = Time.now retries = 0 @@ -251,8 +245,8 @@ def create waiter.before_attempt do |attempts| MU.log "Waiting for cache cluster #{@config['identifier']} to become available", MU::NOTICE if attempts % 5 == 0 end - waiter.before_wait do |attempts, resp| - throw :success if resp.cache_clusters.first.cache_cluster_status == "available" + waiter.before_wait do |attempts, r| + throw :success if r.cache_clusters.first.cache_cluster_status == "available" throw :failure if Time.now - wait_start_time > 1800 end end @@ -272,7 +266,6 @@ def create # Create a subnet group for a Cache Cluster with the given config. def createSubnetGroup subnet_ids = [] - if @config["vpc"] && !@config["vpc"].empty? raise MuError, "Didn't find the VPC specified in #{@config["vpc"]}" unless @vpc @@ -318,8 +311,8 @@ def createSubnetGroup } @config['vpc'] = { - "vpc_id" => vpc_id, - "subnets" => mu_subnets + "vpc_id" => vpc_id, + "subnets" => mu_subnets } using_default_vpc = true MU.log "Using default VPC for cache cluster #{@config['identifier']}" @@ -358,8 +351,8 @@ def createSubnetGroup if @dependencies.has_key?('firewall_rule') @config["security_group_ids"] = [] - @dependencies['firewall_rule'].values.each { |sg| - @config["security_group_ids"] << sg.cloud_id + @dependencies['firewall_rule'].values.each { |sg| + @config["security_group_ids"] << sg.cloud_id } end end @@ -703,6 +696,10 @@ def self.cleanup(noop: false, ignoremaster: false, credentials: nil, region: MU. def self.schema(config) toplevel_required = [] schema = { + "create_replication_group" => { + "type" => "boolean", + "description" => "Create a replication group; will be set automatically if +engine+ is +redis+ and +node_count+ is greated than one." + }, "ingress_rules" => { "items" => { "properties" => { @@ -734,6 +731,32 @@ def self.schema(config) def self.validateConfig(cache, configurator) ok = true + if !cache['vpc'] + siblings = configurator.haveLitterMate?(nil, "vpcs", has_multiple: true) + if siblings.size == 1 + MU.log "CacheCluster #{cache['name']} did not declare a VPC. Inserting into sibling VPC #{siblings[0]['name']}.", MU::WARN + cache["vpc"] = { + "name" => siblings[0]['name'], + "subnet_pref" => "all_private" + } + elsif MU::Cloud::AWS.hosted? and MU::Cloud::AWS.myVPCObj + cache["vpc"] = { + "id" => MU.myVPC, + "subnet_pref" => "all_private" + } + else + MU.log "CacheCluster #{cache['name']} must declare a VPC", MU::ERR + ok = false + end + + # Re-insert ourselves with this modification so that our child + # resources get this VPC we just shoved in + if ok and cache['vpc'] + cache.delete("#MU_VALIDATED") + return configurator.insertKitten(cache, "cache_clusters", overwrite: true) + end + end + if cache.has_key?("parameter_group_parameters") && cache["parameter_group_family"].nil? MU.log "parameter_group_family must be set when setting parameter_group_parameters", MU::ERR ok = false @@ -755,7 +778,6 @@ def self.validateConfig(cache, configurator) end elsif cache["engine"] == "memcached" cache["create_replication_group"] = false - cache["az_mode"] = cache["multi_az"] ? "cross-az" : "single-az" if cache["node_count"] > 20 MU.log "#{cache['engine']} supports up to 20 nodes per cache cluster", MU::ERR @@ -880,7 +902,7 @@ def self.clusterCreateSnap(cluster_id, region, credentials) # @param region [String]: The cloud provider's region in which to operate. # @param cloud_id [String]: The cloud provider's identifier for this resource. # @return [void] - def self.terminate_replication_group(repl_group, noop: false, skipsnapshots: false, region: MU.curRegion, deploy_id: MU.deploy_id, mu_name: nil, cloud_id: nil) + def self.terminate_replication_group(repl_group, noop: false, skipsnapshots: false, region: MU.curRegion, deploy_id: MU.deploy_id, mu_name: nil, cloud_id: nil, credentials: nil) raise MuError, "terminate_replication_group requires a non-nil cache replication group descriptor" if repl_group.nil? || repl_group.empty? repl_group_id = repl_group.replication_group_id @@ -920,9 +942,9 @@ def self.skipSnap(repl_group_id, region, credentials) ) end - def self.createSnap(repl_group_id, region) + def self.createSnap(repl_group_id, region, credentials) MU.log "Terminating #{repl_group_id}. Final snapshot name: #{repl_group_id}-mufinal" - MU::Cloud::AWS.elasticache(region: region).delete_replication_group( + MU::Cloud::AWS.elasticache(region: region, credentials: credentials).delete_replication_group( replication_group_id: repl_group_id, retain_primary_cluster: false, final_snapshot_identifier: "#{repl_group_id}-mufinal" diff --git a/modules/mu/clouds/aws/collection.rb b/modules/mu/clouds/aws/collection.rb index 912be56b7..20550a92f 100644 --- a/modules/mu/clouds/aws/collection.rb +++ b/modules/mu/clouds/aws/collection.rb @@ -20,21 +20,11 @@ class AWS # An Amazon CloudFormation stack as configured in {MU::Config::BasketofKittens::collections} class Collection < MU::Cloud::Collection - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::vpcs} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - if !mu_name.nil? - @mu_name = mu_name - else - @mu_name = @deploy.getResourceName(@config['name'], need_unique_string: true) - end + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + @mu_name ||= @deploy.getResourceName(@config['name'], need_unique_string: true) MU.setVar("curRegion", @config['region']) if !@config['region'].nil? end @@ -104,7 +94,7 @@ def create else # json file and template path is same file_dir =File.dirname(ARGV[0]) - if File.exists? file_dir+"/"+@config["template_file"] then + if File.exist? file_dir+"/"+@config["template_file"] then template_body=File.read(file_dir+"/"+@config["template_file"]); end end @@ -158,7 +148,7 @@ def create case resource.resource_type when "AWS::EC2::Instance" - MU::MommaCat.createStandardTags(resource.physical_resource_id) + MU::Cloud::AWS.createStandardTags(resource.physical_resource_id) instance_name = MU.deploy_id+"-"+@config['name']+"-"+resource.logical_resource_id MU::MommaCat.createTag(resource.physical_resource_id, "Name", instance_name, credentials: @config['credentials']) @@ -186,14 +176,14 @@ def create end when "AWS::EC2::SecurityGroup" - MU::MommaCat.createStandardTags(resource.physical_resource_id) + MU::Cloud::AWS.createStandardTags(resource.physical_resource_id) MU::MommaCat.createTag(resource.physical_resource_id, "Name", MU.deploy_id+"-"+@config['name']+'-'+resource.logical_resource_id, credentials: @config['credentials']) MU::Cloud::AWS::FirewallRule.notifyDeploy( @config['name']+"-"+resource.logical_resource_id, resource.physical_resource_id ) when "AWS::EC2::Subnet" - MU::MommaCat.createStandardTags(resource.physical_resource_id) + MU::Cloud::AWS.createStandardTags(resource.physical_resource_id) MU::MommaCat.createTag(resource.physical_resource_id, "Name", MU.deploy_id+"-"+@config['name']+'-'+resource.logical_resource_id, credentials: @config['credentials']) data = { "collection" => @config["name"], @@ -201,7 +191,7 @@ def create } @deploy.notify("subnets", @config['name']+"-"+resource.logical_resource_id, data) when "AWS::EC2::VPC" - MU::MommaCat.createStandardTags(resource.physical_resource_id) + MU::Cloud::AWS.createStandardTags(resource.physical_resource_id) MU::MommaCat.createTag(resource.physical_resource_id, "Name", MU.deploy_id+"-"+@config['name']+'-'+resource.logical_resource_id, credentials: @config['credentials']) data = { "collection" => @config["name"], @@ -209,10 +199,10 @@ def create } @deploy.notify("vpcs", @config['name']+"-"+resource.logical_resource_id, data) when "AWS::EC2::InternetGateway" - MU::MommaCat.createStandardTags(resource.physical_resource_id) + MU::Cloud::AWS.createStandardTags(resource.physical_resource_id) MU::MommaCat.createTag(resource.physical_resource_id, "Name", MU.deploy_id+"-"+@config['name']+'-'+resource.logical_resource_id, credentials: @config['credentials']) when "AWS::EC2::RouteTable" - MU::MommaCat.createStandardTags(resource.physical_resource_id) + MU::Cloud::AWS.createStandardTags(resource.physical_resource_id) MU::MommaCat.createTag(resource.physical_resource_id, "Name", MU.deploy_id+"-"+@config['name']+'-'+resource.logical_resource_id, credentials: @config['credentials']) # The rest of these aren't anything we act on @@ -307,13 +297,13 @@ def arn end # placeholder - def self.find(cloud_id: nil, region: MU.myRegion, credentials: nil) + def self.find(**args) found = nil - resp = MU::Cloud::AWS.cloudformation(region: region, credentials: credentials).describe_stacks( - stack_name: cloud_id + resp = MU::Cloud::AWS.cloudformation(region: args[:region], credentials: args[:credentials]).describe_stacks( + stack_name: args[:cloud_id] ) if resp and resp.stacks - found[cloud_id] = resp.stacks.first + found[args[:cloud_id]] = resp.stacks.first end found diff --git a/modules/mu/clouds/aws/container_cluster.rb b/modules/mu/clouds/aws/container_cluster.rb index da4ec35ef..40da9794b 100644 --- a/modules/mu/clouds/aws/container_cluster.rb +++ b/modules/mu/clouds/aws/container_cluster.rb @@ -17,26 +17,12 @@ class Cloud class AWS # A ContainerCluster as configured in {MU::Config::BasketofKittens::container_clusters} class ContainerCluster < MU::Cloud::ContainerCluster - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - @cloudformation_data = {} - attr_reader :cloudformation_data - # Return the list of regions where we know EKS is supported. - def self.EKSRegions - # XXX would prefer to query service API for this - ["us-east-1", "us-west-2", "eu-west-1"] - end - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::container_clusters} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"]) end @@ -61,16 +47,28 @@ def create resp = nil begin - MU.log "Creating EKS cluster #{@mu_name}" - resp = MU::Cloud::AWS.eks(region: @config['region'], credentials: @config['credentials']).create_cluster( - name: @mu_name, - version: @config['kubernetes']['version'], - role_arn: role_arn, - resources_vpc_config: { - security_group_ids: security_groups, - subnet_ids: subnet_ids + params = { + :name => @mu_name, + :version => @config['kubernetes']['version'], + :role_arn => role_arn, + :resources_vpc_config => { + :security_group_ids => security_groups, + :subnet_ids => subnet_ids } - ) + } + if @config['logging'] and @config['logging'].size > 0 + params[:logging] = { + :cluster_logging => [ + { + :types => @config['logging'], + :enabled => true + } + ] + } + end + + MU.log "Creating EKS cluster #{@mu_name}", details: params + resp = MU::Cloud::AWS.eks(region: @config['region'], credentials: @config['credentials']).create_cluster(params) rescue Aws::EKS::Errors::UnsupportedAvailabilityZoneException => e # this isn't the dumbest thing we've ever done, but it's up there if e.message.match(/because (#{Regexp.quote(@config['region'])}[a-z]), the targeted availability zone, does not currently have sufficient capacity/) @@ -89,14 +87,13 @@ def create retry end rescue Aws::EKS::Errors::InvalidParameterException => e - if e.message.match(/role with arn: #{Regexp.quote(role_arn)}.*?(could not be assumed|does not exist)/) + if e.message.match(/role with arn: #{Regexp.quote(role_arn)}.*?(could not be assumed|does not exist)/i) sleep 5 retry else MU.log e.message, MU::WARN, details: role_arn sleep 5 retry - puts e.message end end @@ -107,6 +104,9 @@ def create name: @mu_name ) status = resp.cluster.status + if status == "FAILED" + raise MuError, "EKS cluster #{@mu_name} had FAILED status" + end if retries > 0 and (retries % 3) == 0 and status != "ACTIVE" MU.log "Waiting for EKS cluster #{@mu_name} to become active (currently #{status})", MU::NOTICE end @@ -130,6 +130,7 @@ def create MU::Cloud::AWS.ecs(region: @config['region'], credentials: @config['credentials']).create_cluster( cluster_name: @mu_name ) + end @cloud_id = @mu_name end @@ -140,8 +141,18 @@ def groom serverpool = @deploy.findLitterMate(type: "server_pools", name: @config["name"]+"workers") resource_lookup = MU::Cloud::AWS.listInstanceTypes(@config['region'])[@config['region']] - if @config['kubernetes'] - kube = ERB.new(File.read(MU.myRoot+"/cookbooks/mu-tools/templates/default/kubeconfig.erb")) + if @config['flavor'] == "EKS" + # This will be needed if a loadbalancer has never been created in + # this account; EKS applications might want one, but will fail in + # confusing ways if this hasn't been done. + begin + MU::Cloud::AWS.iam(credentials: @config['credentials']).create_service_linked_role( + aws_service_name: "elasticloadbalancing.amazonaws.com" + ) + rescue ::Aws::IAM::Errors::InvalidInput + end + + kube = ERB.new(File.read(MU.myRoot+"/cookbooks/mu-tools/templates/default/kubeconfig-eks.erb")) configmap = ERB.new(File.read(MU.myRoot+"/extras/aws-auth-cm.yaml.erb")) tagme = [@vpc.cloud_id] tagme_elb = [] @@ -154,7 +165,7 @@ def groom ).route_tables tagme.concat(rtbs.map { |r| r.route_table_id } ) main_sg = @deploy.findLitterMate(type: "firewall_rules", name: "server_pool#{@config['name']}workers") - tagme << main_sg.cloud_id + tagme << main_sg.cloud_id if main_sg MU.log "Applying kubernetes.io tags to VPC resources", details: tagme MU::Cloud::AWS.createTag("kubernetes.io/cluster/#{@mu_name}", "shared", tagme, credentials: @config['credentials']) MU::Cloud::AWS.createTag("kubernetes.io/cluster/elb", @mu_name, tagme_elb, credentials: @config['credentials']) @@ -179,42 +190,31 @@ def groom File.open(gitlab_helper, "w"){ |k| k.puts gitlab.result(binding) } + authmap_cmd = %Q{#{MU::Master.kubectl} --kubeconfig "#{kube_conf}" apply -f "#{eks_auth}"} - authmap_cmd = %Q{/opt/mu/bin/kubectl --kubeconfig "#{kube_conf}" apply -f "#{eks_auth}"} - MU.log "Configuring Kubernetes <=> IAM mapping for worker nodes", details: authmap_cmd + authmap_cmd = %Q{#{MU::Master.kubectl} --kubeconfig "#{kube_conf}" apply -f "#{eks_auth}"} + MU.log "Configuring Kubernetes <=> IAM mapping for worker nodes", MU::NOTICE, details: authmap_cmd # maybe guard this mess %x{#{authmap_cmd}} # and this one - admin_user_cmd = %Q{/opt/mu/bin/kubectl --kubeconfig "#{kube_conf}" apply -f "#{MU.myRoot}/extras/admin-user.yaml"} - admin_role_cmd = %Q{/opt/mu/bin/kubectl --kubeconfig "#{kube_conf}" apply -f "#{MU.myRoot}/extras/admin-role-binding.yaml"} - MU.log "Configuring Kubernetes admin-user and role", details: admin_user_cmd+"\n"+admin_role_cmd + admin_user_cmd = %Q{#{MU::Master.kubectl} --kubeconfig "#{kube_conf}" apply -f "#{MU.myRoot}/extras/admin-user.yaml"} + admin_role_cmd = %Q{#{MU::Master.kubectl} --kubeconfig "#{kube_conf}" apply -f "#{MU.myRoot}/extras/admin-role-binding.yaml"} + MU.log "Configuring Kubernetes admin-user and role", MU::NOTICE, details: admin_user_cmd+"\n"+admin_role_cmd %x{#{admin_user_cmd}} %x{#{admin_role_cmd}} if @config['kubernetes_resources'] - count = 0 - @config['kubernetes_resources'].each { |blob| - blobfile = @deploy.deploy_dir+"/k8s-resource-#{count.to_s}-#{@config['name']}" - File.open(blobfile, "w") { |f| - f.puts blob.to_yaml - } - %x{/opt/mu/bin/kubectl --kubeconfig "#{kube_conf}" get -f #{blobfile} > /dev/null 2>&1} - arg = $?.exitstatus == 0 ? "replace" : "create" - cmd = %Q{/opt/mu/bin/kubectl --kubeconfig "#{kube_conf}" #{arg} -f #{blobfile}} - MU.log "Applying Kubernetes resource #{count.to_s} with kubectl #{arg}", details: cmd - output = %x{#{cmd} 2>&1} - if $?.exitstatus == 0 - MU.log "Kuberentes resource #{count.to_s} #{arg} was successful: #{output}", details: blob.to_yaml - else - MU.log "Kuberentes resource #{count.to_s} #{arg} failed: #{output}", MU::WARN, details: blob.to_yaml - end - count += 1 - } + MU::Master.applyKubernetesResources( + @config['name'], + @config['kubernetes_resources'], + kubeconfig: kube_conf, + outputdir: @deploy.deploy_dir + ) end - MU.log %Q{How to interact with your Kubernetes cluster\nkubectl --kubeconfig "#{kube_conf}" get all\nkubectl --kubeconfig "#{kube_conf}" create -f some_k8s_deploy.yml}, MU::SUMMARY - else + MU.log %Q{How to interact with your EKS cluster\nkubectl --kubeconfig "#{kube_conf}" get all\nkubectl --kubeconfig "#{kube_conf}" create -f some_k8s_deploy.yml\nkubectl --kubeconfig "#{kube_conf}" get nodes}, MU::SUMMARY + elsif @config['flavor'] != "Fargate" resp = MU::Cloud::AWS.ecs(region: @config['region'], credentials: @config['credentials']).list_container_instances({ cluster: @mu_name }) @@ -281,7 +281,385 @@ def groom } } end -# launch_type: "EC2" only option in GovCloud + + if @config['flavor'] != "EKS" and @config['containers'] + + security_groups = [] + if @dependencies.has_key?("firewall_rule") + @dependencies['firewall_rule'].values.each { |sg| + security_groups << sg.cloud_id + } + end + + tasks_registered = 0 + retries = 0 + svc_resp = begin + MU::Cloud::AWS.ecs(region: @config['region'], credentials: @config['credentials']).list_services( + cluster: arn + ) + rescue Aws::ECS::Errors::ClusterNotFoundException => e + if retries < 10 + sleep 5 + retries += 1 + retry + else + raise e + end + end + existing_svcs = svc_resp.service_arns.map { |s| + s.gsub(/.*?:service\/(.*)/, '\1') + } + + # Reorganize things so that we have services and task definitions + # mapped to the set of containers they must contain + tasks = {} + created_generic_loggroup = false + + @config['containers'].each { |c| + service_name = c['service'] ? @mu_name+"-"+c['service'].upcase : @mu_name + tasks[service_name] ||= [] + tasks[service_name] << c + } + + tasks.each_pair { |service_name, containers| + launch_type = @config['flavor'] == "ECS" ? "EC2" : "FARGATE" + cpu_total = 0 + mem_total = 0 + role_arn = nil + lbs = [] + + container_definitions = containers.map { |c| + container_name = @mu_name+"-"+c['name'].upcase + cpu_total += c['cpu'] + mem_total += c['memory'] + + if c["role"] and !role_arn + found = MU::MommaCat.findStray( + @config['cloud'], + "role", + cloud_id: c["role"]["id"], + name: c["role"]["name"], + deploy_id: c["role"]["deploy_id"] || @deploy.deploy_id, + dummy_ok: false + ) + if found + found = found.first + if found and found.cloudobj + role_arn = found.cloudobj.arn + end + else + raise MuError, "Unable to find execution role from #{c["role"]}" + end + end + + if c['loadbalancers'] != [] + c['loadbalancers'].each {|lb| + found = @deploy.findLitterMate(name: lb['name'], type: "loadbalancer") + if found + MU.log "Mapping LB #{found.mu_name} to service #{c['name']}", MU::INFO + if found.cloud_desc.type != "classic" + elb_groups = MU::Cloud::AWS.elb2(region: @config['region'], credentials: @config['credentials']).describe_target_groups({ + load_balancer_arn: found.cloud_desc.load_balancer_arn + }) + matching_target_groups = [] + elb_groups.target_groups.each { |tg| + if tg.port.to_i == lb['container_port'].to_i + matching_target_groups << { + arn: tg['target_group_arn'], + name: tg['target_group_name'] + } + end + } + if matching_target_groups.length >= 1 + MU.log "#{matching_target_groups.length} matching target groups found. Mapping #{container_name} to target group #{matching_target_groups.first['name']}", MU::INFO + lbs << { + container_name: container_name, + container_port: lb['container_port'], + target_group_arn: matching_target_groups.first[:arn] + } + else + raise MuError, "No matching target groups found" + end + elsif @config['flavor'] == "Fargate" && found.cloud_desc.type == "classic" + raise MuError, "Classic Load Balancers are not supported with Fargate." + else + MU.log "Mapping Classic LB #{found.mu_name} to service #{container_name}", MU::INFO + lbs << { + container_name: container_name, + container_port: lb['container_port'], + load_balancer_name: found.mu_name + } + end + else + raise MuError, "Unable to find loadbalancers from #{c["loadbalancers"].first['name']}" + end + } + end + + params = { + name: @mu_name+"-"+c['name'].upcase, + image: c['image'], + memory: c['memory'], + cpu: c['cpu'] + } + if !@config['vpc'] + c['hostname'] ||= @mu_name+"-"+c['name'].upcase + end + [:essential, :hostname, :start_timeout, :stop_timeout, :user, :working_directory, :disable_networking, :privileged, :readonly_root_filesystem, :interactive, :pseudo_terminal, :links, :entry_point, :command, :dns_servers, :dns_search_domains, :docker_security_options, :port_mappings, :repository_credentials, :mount_points, :environment, :volumes_from, :secrets, :depends_on, :extra_hosts, :docker_labels, :ulimits, :system_controls, :health_check, :resource_requirements].each { |param| + if c.has_key?(param.to_s) + params[param] = if !c[param.to_s].nil? and (c[param.to_s].is_a?(Hash) or c[param.to_s].is_a?(Array)) + MU.strToSym(c[param.to_s]) + else + c[param.to_s] + end + end + } + if @config['vpc'] + [:hostname, :dns_servers, :dns_search_domains, :links].each { |param| + if params[param] + MU.log "Container parameter #{param.to_s} not supported in VPC clusters, ignoring", MU::WARN + params.delete(param) + end + } + end + if @config['flavor'] == "Fargate" + [:privileged, :docker_security_options].each { |param| + if params[param] + MU.log "Container parameter #{param.to_s} not supported in Fargate clusters, ignoring", MU::WARN + params.delete(param) + end + } + end + if c['log_configuration'] + log_obj = @deploy.findLitterMate(name: c['log_configuration']['options']['awslogs-group'], type: "logs") + if log_obj + c['log_configuration']['options']['awslogs-group'] = log_obj.mu_name + end + params[:log_configuration] = MU.strToSym(c['log_configuration']) + end + params + } + + cpu_total = 2 if cpu_total == 0 + mem_total = 2 if mem_total == 0 + + task_params = { + family: @deploy.deploy_id, + container_definitions: container_definitions, + requires_compatibilities: [launch_type] + } + + if @config['volumes'] + task_params[:volumes] = [] + @config['volumes'].each { |v| + vol = { :name => v['name'] } + if v['type'] == "host" + vol[:host] = {} + if v['host_volume_source_path'] + vol[:host][:source_path] = v['host_volume_source_path'] + end + elsif v['type'] == "docker" + vol[:docker_volume_configuration] = MU.strToSym(v['docker_volume_configuration']) + else + raise MuError, "Invalid volume type '#{v['type']}' specified in ContainerCluster '#{@mu_name}'" + end + task_params[:volumes] << vol + } + end + + if role_arn + task_params[:execution_role_arn] = role_arn + task_params[:task_role_arn] = role_arn + end + if @config['flavor'] == "Fargate" + task_params[:network_mode] = "awsvpc" + task_params[:cpu] = cpu_total.to_i.to_s + task_params[:memory] = mem_total.to_i.to_s + end + + tasks_registered += 1 + MU.log "Registering task definition #{service_name} with #{container_definitions.size.to_s} containers" + +# XXX this helpfully keeps revisions, but let's compare anyway and avoid cluttering with identical ones + resp = MU::Cloud::AWS.ecs(region: @config['region'], credentials: @config['credentials']).register_task_definition(task_params) + + task_def = resp.task_definition.task_definition_arn + service_params = { + :cluster => @mu_name, + :desired_count => @config['instance_count'], # XXX this makes no sense + :service_name => service_name, + :launch_type => launch_type, + :task_definition => task_def, + :load_balancers => lbs + } + if @config['vpc'] + subnet_ids = [] + all_public = true + + subnets = + if @config["vpc"]["subnets"].empty? + @vpc.subnets + else + subnet_objects= [] + @config["vpc"]["subnets"].each { |subnet| + sobj = @vpc.getSubnet(cloud_id: subnet["subnet_id"], name: subnet["subnet_name"]) + if sobj.nil? + MU.log "Got nil result from @vpc.getSubnet(cloud_id: #{subnet["subnet_id"]}, name: #{subnet["subnet_name"]})", MU::WARN + else + subnet_objects << sobj + end + } + subnet_objects + end + + subnets.each { |subnet_obj| + subnet_ids << subnet_obj.cloud_id + all_public = false if subnet_obj.private? + } + + service_params[:network_configuration] = { + :awsvpc_configuration => { + :subnets => subnet_ids, + :security_groups => security_groups, + :assign_public_ip => all_public ? "ENABLED" : "DISABLED" + } + } + end + + if !existing_svcs.include?(service_name) + MU.log "Creating Service #{service_name}" + + resp = MU::Cloud::AWS.ecs(region: @config['region'], credentials: @config['credentials']).create_service(service_params) + else + service_params[:service] = service_params[:service_name].dup + service_params.delete(:service_name) + service_params.delete(:launch_type) + MU.log "Updating Service #{service_name}", MU::NOTICE, details: service_params + + resp = MU::Cloud::AWS.ecs(region: @config['region'], credentials: @config['credentials']).update_service(service_params) + end + existing_svcs << service_name + } + + max_retries = 10 + retries = 0 + if tasks_registered > 0 + retry_me = false + begin + retry_me = !MU::Cloud::AWS::ContainerCluster.tasksRunning?(@mu_name, log: (retries > 0), region: @config['region'], credentials: @config['credentials']) + retries += 1 + sleep 15 if retry_me + end while retry_me and retries < max_retries + tasks = nil + + if retry_me + MU.log "Not all tasks successfully launched in cluster #{@mu_name}", MU::WARN + end + end + + end + + end + + # Returns true if all tasks in the given ECS/Fargate cluster are in the + # RUNNING state. + # @param cluster [String]: The cluster to check + # @param log [Boolean]: Output the state of each task to Mu's logger facility + # @param region [String] + # @param credentials [String] + # @return [Boolean] + def self.tasksRunning?(cluster, log: true, region: MU.myRegion, credentials: nil) + services = MU::Cloud::AWS.ecs(region: region, credentials: credentials).list_services( + cluster: cluster + ).service_arns.map { |s| s.sub(/.*?:service\/([^\/:]+?)$/, '\1') } + + tasks_defined = [] + + begin + listme = services.slice!(0, (services.length >= 10 ? 10 : services.length)) + if services.size > 0 + tasks_defined.concat( + tasks = MU::Cloud::AWS.ecs(region: region, credentials: credentials).describe_services( + cluster: cluster, + services: listme + ).services.map { |s| s.task_definition } + ) + end + end while services.size > 0 + + containers = {} + + tasks_defined.each { |t| + taskdef = MU::Cloud::AWS.ecs(region: region, credentials: credentials).describe_task_definition( + task_definition: t.sub(/^.*?:task-definition\/([^\/:]+)$/, '\1') + ) + taskdef.task_definition.container_definitions.each { |c| + containers[c.name] = {} + } + } + + tasks = MU::Cloud::AWS.ecs(region: region, credentials: credentials).list_tasks( + cluster: cluster, + desired_status: "RUNNING" + ).task_arns + + tasks.concat(MU::Cloud::AWS.ecs(region: region, credentials: credentials).list_tasks( + cluster: cluster, + desired_status: "STOPPED" + ).task_arns) + + begin + sample = tasks.slice!(0, (tasks.length >= 100 ? 100 : tasks.length)) + break if sample.size == 0 + task_ids = sample.map { |task_arn| + task_arn.sub(/^.*?:task\/([a-f0-9\-]+)$/, '\1') + } + + MU::Cloud::AWS.ecs(region: region, credentials: credentials).describe_tasks( + cluster: cluster, + tasks: task_ids + ).tasks.each { |t| + task_name = t.task_definition_arn.sub(/^.*?:task-definition\/([^\/:]+)$/, '\1') + t.containers.each { |c| + containers[c.name] ||= {} + containers[c.name][t.desired_status] ||= { + "reasons" => [] + } + [t.stopped_reason, c.reason].each { |r| + next if r.nil? + containers[c.name][t.desired_status]["reasons"] << r + } + containers[c.name][t.desired_status]["reasons"].uniq! + if !containers[c.name][t.desired_status]['time'] or + t.created_at > containers[c.name][t.desired_status]['time'] +MU.log c.name, MU::NOTICE, details: t + containers[c.name][t.desired_status] = { + "time" => t.created_at, + "status" => c.last_status, + "reasons" => containers[c.name][t.desired_status]["reasons"] + } + end + } + } + end while tasks.size > 0 + + to_return = true + containers.each_pair { |name, states| + if !states["RUNNING"] or states["RUNNING"]["status"] != "RUNNING" + to_return = false + if states["STOPPED"] and states["STOPPED"]["status"] + MU.log "Container #{name} has failures", MU::WARN, details: states["STOPPED"] if log + elsif states["RUNNING"] and states["RUNNING"]["status"] + MU.log "Container #{name} not currently running", MU::NOTICE, details: states["RUNNING"] if log + else + MU.log "Container #{name} in unknown state", MU::WARN, details: states["STOPPED"] if log + end + else + MU.log "Container #{name} running", details: states["RUNNING"] if log + end + } + + to_return end # Return the cloud layer descriptor for this EKS/ECS/Fargate cluster @@ -322,40 +700,68 @@ def notify return deploy_struct end + @@eks_versions = {} + @@eks_version_semaphore = Mutex.new # Use the AWS SSM API to fetch the current version of the Amazon Linux # ECS-optimized AMI, so we can use it as a default AMI for ECS deploys. # @param flavor [String]: ECS or EKS - def self.getECSImageId(flavor = "ECS", region = MU.myRegion) - if flavor == "ECS" - resp = MU::Cloud::AWS.ssm(region: region).get_parameters( + # @param region [String]: Target AWS region + # @param version [String]: Version of Kubernetes, if +flavor+ is set to +EKS+ + # @param gpu [Boolean]: Whether to request an image with GPU support + def self.getStandardImage(flavor = "ECS", region = MU.myRegion, version: nil, gpu: false) + resp = if flavor == "ECS" + MU::Cloud::AWS.ssm(region: region).get_parameters( names: ["/aws/service/#{flavor.downcase}/optimized-ami/amazon-linux/recommended"] ) - if resp and resp.parameters and resp.parameters.size > 0 - image_details = JSON.parse(resp.parameters.first.value) - return image_details['image_id'] + else + @@eks_version_semaphore.synchronize { + if !@@eks_versions[region] + @@eks_versions[region] ||= [] + versions = {} + resp = nil + next_token = nil + begin + resp = MU::Cloud::AWS.ssm(region: region).get_parameters_by_path( + path: "/aws/service/#{flavor.downcase}", + recursive: true, + next_token: next_token + ) + resp.parameters.each { |p| + p.name.match(/\/aws\/service\/eks\/optimized-ami\/([^\/]+?)\//) + versions[Regexp.last_match[1]] = true + } + next_token = resp.next_token + end while !next_token.nil? + @@eks_versions[region] = versions.keys.sort { |a, b| MU.version_sort(a, b) } + end + } + if !version or version == "latest" + version = @@eks_versions[region].last end - elsif flavor == "EKS" - # XXX this is absurd, but these don't appear to be available from an API anywhere - # Here's their Packer build, should just convert to Chef: https://github.com/awslabs/amazon-eks-ami - amis = { "us-east-1" => "ami-0440e4f6b9713faf6", "us-west-2" => "ami-0a54c984b9f908c81", "eu-west-1" => "ami-0c7a4976cb6fafd3a" } - return amis[region] + MU::Cloud::AWS.ssm(region: region).get_parameters( + names: ["/aws/service/#{flavor.downcase}/optimized-ami/#{version}/amazon-linux-2#{gpu ? "-gpu" : ""}/recommended"] + ) end - nil - end - # Use the AWS SSM API to fetch the current version of the Amazon Linux - # EKS-optimized AMI, so we can use it as a default AMI for EKS deploys. - def self.getEKSImageId(region = MU.myRegion) - resp = MU::Cloud::AWS.ssm(region: region).get_parameters( - names: ["/aws/service/ekss/optimized-ami/amazon-linux/recommended"] - ) if resp and resp.parameters and resp.parameters.size > 0 image_details = JSON.parse(resp.parameters.first.value) return image_details['image_id'] end + nil end + # Return the list of regions where we know EKS is supported. + def self.EKSRegions(credentials = nil) + eks_regions = [] + MU::Cloud::AWS.listRegions(credentials: credentials).each { |r| + ami = getStandardImage("EKS", r) + eks_regions << r if ami + } + + eks_regions + end + # Does this resource type exist as a global (cloud-wide) artifact, or # is it localized to a region/zone? # @return [Boolean] @@ -377,16 +783,35 @@ def self.quality def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) resp = MU::Cloud::AWS.ecs(credentials: credentials, region: region).list_clusters + if resp and resp.cluster_arns and resp.cluster_arns.size > 0 resp.cluster_arns.each { |arn| if arn.match(/:cluster\/(#{MU.deploy_id}[^:]+)$/) cluster = Regexp.last_match[1] + + svc_resp = MU::Cloud::AWS.ecs(region: region, credentials: credentials).list_services( + cluster: arn + ) + if svc_resp and svc_resp.service_arns + svc_resp.service_arns.each { |svc_arn| + svc_name = svc_arn.gsub(/.*?:service\/(.*)/, '\1') + MU.log "Deleting Service #{svc_name} from ECS Cluster #{cluster}" + if !noop + MU::Cloud::AWS.ecs(region: region, credentials: credentials).delete_service( + cluster: arn, + service: svc_name, + force: true # man forget scaling up and down if we're just deleting the cluster + ) + end + } + end + instances = MU::Cloud::AWS.ecs(credentials: credentials, region: region).list_container_instances({ cluster: cluster }) if instances - instances.container_instance_arns.each { |arn| - uuid = arn.sub(/^.*?:container-instance\//, "") + instances.container_instance_arns.each { |instance_arn| + uuid = instance_arn.sub(/^.*?:container-instance\//, "") MU.log "Deregistering instance #{uuid} from ECS Cluster #{cluster}" if !noop resp = MU::Cloud::AWS.ecs(credentials: credentials, region: region).deregister_container_instance({ @@ -400,17 +825,44 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent MU.log "Deleting ECS Cluster #{cluster}" if !noop # TODO de-register container instances + begin deletion = MU::Cloud::AWS.ecs(credentials: credentials, region: region).delete_cluster( cluster: cluster ) + rescue Aws::ECS::Errors::ClusterContainsTasksException => e + sleep 5 + retry + end end end } end + + tasks = MU::Cloud::AWS.ecs(region: region, credentials: credentials).list_task_definitions( + family_prefix: MU.deploy_id + ) + + if tasks and tasks.task_definition_arns + tasks.task_definition_arns.each { |arn| + MU.log "Deregistering Fargate task definition #{arn}" + if !noop + MU::Cloud::AWS.ecs(region: region, credentials: credentials).deregister_task_definition( + task_definition: arn + ) + end + } + end + return if !MU::Cloud::AWS::ContainerCluster.EKSRegions.include?(region) + resp = begin + MU::Cloud::AWS.eks(credentials: credentials, region: region).list_clusters + rescue Aws::EKS::Errors::AccessDeniedException + # EKS isn't actually live in this region, even though SSM lists + # base images for it + return + end - resp = MU::Cloud::AWS.eks(credentials: credentials, region: region).list_clusters if resp and resp.clusters resp.clusters.each { |cluster| @@ -469,15 +921,10 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent end # Locate an existing container_clusters. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching container_clusters. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) - MU.log cloud_id, MU::WARN, details: flags - MU.log region, MU::WARN - resp = MU::Cloud::AWS.ecs(region: region, credentials: credentials).list_clusters - resp = MU::Cloud::AWS.eks(region: region, credentials: credentials).list_clusters + # @return [Hash]: The cloud provider's complete descriptions of matching container_clusters. + def self.find(**args) + resp = MU::Cloud::AWS.ecs(region: args[:region], credentials: args[:credentials]).list_clusters + resp = MU::Cloud::AWS.eks(region: args[:region], credentials: args[:credentials]).list_clusters # XXX uh, this ain't complete end @@ -486,24 +933,584 @@ def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource def self.schema(config) toplevel_required = [] + schema = { "flavor" => { - "enum" => ["ECS", "EKS", "Fargate"], + "enum" => ["ECS", "EKS", "Fargate", "Kubernetes"], + "type" => "string", + "description" => "The AWS container platform to deploy", "default" => "ECS" }, + "kubernetes" => { + "default" => { "version" => "latest" } + }, + "gpu" => { + "type" => "boolean", + "default" => false, + "description" => "Enable worker nodes with GPU capabilities" + }, "platform" => { - "description" => "The platform to choose for worker nodes. Will default to Amazon Linux for ECS, CentOS 7 for everything else", - "default" => "centos7" + "description" => "The platform to choose for worker nodes." }, "ami_id" => { "type" => "string", - "description" => "The Amazon EC2 AMI on which to base this cluster's container hosts. Will use the default appropriate for the platform, if not specified." + "description" => "The Amazon EC2 AMI on which to base this cluster's container hosts. Will use the default appropriate for the platform, if not specified. Only valid for EKS and ECS flavors." }, "run_list" => { "type" => "array", "items" => { "type" => "string", - "description" => "An extra Chef run list entry, e.g. role[rolename] or recipe[recipename]s, to be run on worker nodes." + "description" => "An extra Chef run list entry, e.g. role[rolename] or recipe[recipename]s, to be run on worker nodes. Only valid for EKS and ECS flavors." + } + }, + "ingress_rules" => { + "type" => "array", + "items" => MU::Config::FirewallRule.ruleschema, + "default" => [ + { + "egress" => true, + "port" => 443, + "hosts" => [ "0.0.0.0/0" ] + } + ] + }, + "logging" => { + "type" => "array", + "default" => ["authenticator", "api"], + "items" => { + "type" => "string", + "description" => "Cluster CloudWatch logs to enable for EKS clusters.", + "enum" => ["api", "audit", "authenticator", "controllerManager", "scheduler"] + } + }, + "volumes" => { + "type" => "array", + "items" => { + "description" => "Define one or more volumes which can then be referenced by the +mount_points+ parameter inside +containers+. +docker+ volumes are not valid for Fargate clusters. See also https://docs.aws.amazon.com/AmazonECS/latest/developerguide/using_data_volumes.html", + "type" => "object", + "required" => ["name", "type"], + "properties" => { + "name" => { + "type" => "string", + "description" => "Name this volume so it can be referenced by containers." + }, + "type" => { + "type" => "string", + "enum" => ["docker", "host"] + }, + "docker_volume_configuration" => { + "type" => "object", + "default" => { + "autoprovision" => true, + "driver" => "local" + }, + "description" => "This parameter is specified when you are using +docker+ volumes. Docker volumes are only supported when you are using the EC2 launch type. To use bind mounts, specify a +host+ volume instead.", + "properties" => { + "autoprovision" => { + "type" => "boolean", + "description" => "Create the Docker volume if it does not already exist.", + "default" => true + }, + "driver" => { + "type" => "string", + "description" => "The Docker volume driver to use. Note that Windows containers can only use the +local+ driver. This parameter maps to +Driver+ in the Create a volume section of the Docker Remote API and the +xxdriver+ option to docker volume create." + }, + "labels" => { + "description" => "Custom metadata to add to your Docker volume.", + "type" => "object" + }, + "driver_opts" => { + "description" => "A map of Docker driver-specific options passed through. This parameter maps to +DriverOpts+ in the Create a volume section of the Docker Remote API and the +xxopt+ option to docker volume create .", + "type" => "object" + }, + } + }, + "host_volume_source_path" => { + "type" => "string", + "description" => "If specified, and the +type+ of this volume is +host+, data will be stored in the container host in this location and will persist after containers associated with it stop running." + } + } + } + }, + "containers" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "A container image to run on this cluster.", + "required" => ["name", "image"], + "properties" => { + "name" => { + "type" => "string", + "description" => "The name of a container. If you are linking multiple containers together in a task definition, the name of one container can be entered in the +links+ of another container to connect the containers. This parameter maps to +name+ in the Create a container section of the Docker Remote API and the +--name+ option to docker run." + }, + "service" => { + "type" => "string", + "description" => "The Service of which this container will be a component. Default behavior, if unspecified, is to create a service with the name of this container definition and assume they map 1:1." + }, + "image" => { + "type" => "string", + "description" => "A Docker image to run, as a shorthand name for a public Dockerhub image or a full URL to a private container repository (+repository-url/image:tag+ or repository-url/image@digest). See +repository_credentials+ to specify authentication for a container repository.", + }, + "cpu" => { + "type" => "integer", + "default" => 256, + "description" => "CPU to allocate for this container/task. This parameter maps to +CpuShares+ in the Create a container section of the Docker Remote API and the +--cpu-shares+ option to docker run. Not all +cpu+ and +memory+ combinations are valid, particularly when using Fargate, see https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-cpu-memory-error.html" + }, + "memory" => { + "type" => "integer", + "default" => 512, + "description" => "Hard limit of memory to allocate for this container/task. Not all +cpu+ and +memory+ combinations are valid, particularly when using Fargate, see https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-cpu-memory-error.html" + }, + "memory_reservation" => { + "type" => "integer", + "default" => 512, + "description" => "Soft limit of memory to allocate for this container/task. This parameter maps to +MemoryReservation+ in the Create a container section of the Docker Remote API and the +--memory-reservation+ option to docker run." + }, + "role" => MU::Config::Role.reference, + "essential" => { + "type" => "boolean", + "description" => "Flag this container as essential or non-essential to its parent task. If the container fails and is marked essential, the parent task will also be marked as failed.", + "default" => true + }, + "hostname" => { + "type" => "string", + "description" => "Set this container's local hostname. If not specified, will inherit the name of the parent task. Not valid for Fargate clusters. This parameter maps to +Hostname+ in the Create a container section of the Docker Remote API and the +--hostname+ option to docker run." + }, + "user" => { + "type" => "string", + "description" => "The system-level user to use when executing commands inside this container" + }, + "working_directory" => { + "type" => "string", + "description" => "The working directory in which to run commands inside the container." + }, + "disable_networking" => { + "type" => "boolean", + "description" => "This parameter maps to +NetworkDisabled+ in the Create a container section of the Docker Remote API." + }, + "privileged" => { + "type" => "boolean", + "description" => "When this parameter is true, the container is given elevated privileges on the host container instance (similar to the root user). This parameter maps to +Privileged+ in the Create a container section of the Docker Remote API and the +--privileged+ option to docker run. Not valid for Fargate clusters." + }, + "readonly_root_filesystem" => { + "type" => "boolean", + "description" => "This parameter maps to +ReadonlyRootfs+ in the Create a container section of the Docker Remote API and the +--read-only+ option to docker run." + }, + "interactive" => { + "type" => "boolean", + "description" => "When this parameter is +true+, this allows you to deploy containerized applications that require +stdin+ or a +tty+ to be allocated. This parameter maps to +OpenStdin+ in the Create a container section of the Docker Remote API and the +--interactive+ option to docker run." + }, + "pseudo_terminal" => { + "type" => "boolean", + "description" => "When this parameter is true, a TTY is allocated. This parameter maps to +Tty+ in the Create a container section of the Docker Remote API and the +--tty+ option to docker run." + }, + "start_timeout" => { + "type" => "integer", + "description" => "Time duration to wait before giving up on containers which have been specified with +depends_on+ for this one." + }, + "stop_timeout" => { + "type" => "integer", + "description" => "Time duration to wait before the container is forcefully killed if it doesn't exit normally on its own." + }, + "links" => { + "type" => "array", + "items" => { + "description" => "The +link+ parameter allows containers to communicate with each other without the need for port mappings. Only supported if the network mode of a task definition is set to +bridge+. The +name:internalName+ construct is analogous to +name:alias+ in Docker links.", + "type" => "string" + } + }, + "entry_point" => { + "type" => "array", + "items" => { + "type" => "string", + "description" => "The entry point that is passed to the container. This parameter maps to +Entrypoint+ in the Create a container section of the Docker Remote API and the +--entrypoint+ option to docker run." + } + }, + "command" => { + "type" => "array", + "items" => { + "type" => "string", + "description" => "This parameter maps to +Cmd+ in the Create a container section of the Docker Remote API and the +COMMAND+ parameter to docker run." + } + }, + "dns_servers" => { + "type" => "array", + "items" => { + "type" => "string", + "description" => "A list of DNS servers that are presented to the container. This parameter maps to +Dns+ in the Create a container section of the Docker Remote API and the +--dns+ option to docker run." + } + }, + "dns_search_domains" => { + "type" => "array", + "items" => { + "type" => "string", + "description" => "A list of DNS search domains that are presented to the container. This parameter maps to +DnsSearch+ in the Create a container section of the Docker Remote API and the +--dns-search+ option to docker run." + } + }, + "linux_parameters" => { + "type" => "object", + "description" => "Linux-specific options that are applied to the container, such as Linux KernelCapabilities.", + "properties" => { + "init_process_enabled" => { + "type" => "boolean", + "description" => "Run an +init+ process inside the container that forwards signals and reaps processes. This parameter maps to the +--init+ option to docker run." + }, + "shared_memory_size" => { + "type" => "integer", + "description" => "The value for the size (in MiB) of the +/dev/shm+ volume. This parameter maps to the +--shm-size+ option to docker run. Not valid for Fargate clusters." + }, + "capabilities" => { + "type" => "object", + "description" => "The Linux capabilities for the container that are added to or dropped from the default configuration provided by Docker.", + "properties" => { + "add" => { + "type" => "array", + "items" => { + "type" => "string", + "description" => "This parameter maps to +CapAdd+ in the Create a container section of the Docker Remote API and the +--cap-add+ option to docker run. Not valid for Fargate clusters.", + "enum" => ["ALL", "AUDIT_CONTROL", "AUDIT_WRITE", "BLOCK_SUSPEND", "CHOWN", "DAC_OVERRIDE", "DAC_READ_SEARCH", "FOWNER", "FSETID", "IPC_LOCK", "IPC_OWNER", "KILL", "LEASE", "LINUX_IMMUTABLE", "MAC_ADMIN", "MAC_OVERRIDE", "MKNOD", "NET_ADMIN", "NET_BIND_SERVICE", "NET_BROADCAST", "NET_RAW", "SETFCAP", "SETGID", "SETPCAP", "SETUID", "SYS_ADMIN", "SYS_BOOT", "SYS_CHROOT", "SYS_MODULE", "SYS_NICE", "SYS_PACCT", "SYS_PTRACE", "SYS_RAWIO", "SYS_RESOURCE", "SYS_TIME", "SYS_TTY_CONFIG", "SYSLOG", "WAKE_ALARM"] + } + }, + "drop" => { + "type" => "array", + "items" => { + "type" => "string", + "description" => "This parameter maps to +CapDrop+ in the Create a container section of the Docker Remote API and the +--cap-drop+ option to docker run.", + "enum" => ["ALL", "AUDIT_CONTROL", "AUDIT_WRITE", "BLOCK_SUSPEND", "CHOWN", "DAC_OVERRIDE", "DAC_READ_SEARCH", "FOWNER", "FSETID", "IPC_LOCK", "IPC_OWNER", "KILL", "LEASE", "LINUX_IMMUTABLE", "MAC_ADMIN", "MAC_OVERRIDE", "MKNOD", "NET_ADMIN", "NET_BIND_SERVICE", "NET_BROADCAST", "NET_RAW", "SETFCAP", "SETGID", "SETPCAP", "SETUID", "SYS_ADMIN", "SYS_BOOT", "SYS_CHROOT", "SYS_MODULE", "SYS_NICE", "SYS_PACCT", "SYS_PTRACE", "SYS_RAWIO", "SYS_RESOURCE", "SYS_TIME", "SYS_TTY_CONFIG", "SYSLOG", "WAKE_ALARM"] + } + } + } + }, + "devices" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "Host devices to expose to the container.", + "properties" => { + "host_path" => { + "type" => "string", + "description" => "The path for the device on the host container instance." + }, + "container_path" => { + "type" => "string", + "description" => "The path inside the container at which to expose the host device." + }, + "permissions" => { + "type" => "array", + "items" => { + "description" => "The explicit permissions to provide to the container for the device. By default, the container has permissions for +read+, +write+, and +mknod+ for the device.", + "type" => "string" + } + } + } + } + }, + "tmpfs" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "A tmpfs device to expost to the container. This parameter maps to the +--tmpfs+ option to docker run. Not valid for Fargate clusters.", + "properties" => { + "container_path" => { + "type" => "string", + "description" => "The absolute file path where the tmpfs volume is to be mounted." + }, + "size" => { + "type" => "integer", + "description" => "The size (in MiB) of the tmpfs volume." + }, + "mount_options" => { + "type" => "array", + "items" => { + "description" => "tmpfs volume mount options", + "type" => "string", + "enum" => ["defaults", "ro", "rw", "suid", "nosuid", "dev", "nodev", "exec", "noexec", "sync", "async", "dirsync", "remount", "mand", "nomand", "atime", "noatime", "diratime", "nodiratime", "bind", "rbind", "unbindable", "runbindable", "private", "rprivate", "shared", "rshared", "slave", "rslave", "relatime", "norelatime", "strictatime", "nostrictatime", "mode", "uid", "gid", "nr_inodes", "nr_blocks", "mpol"] + } + } + } + } + } + } + }, + "docker_labels" => { + "type" => "object", + "description" => "A key/value map of labels to add to the container. This parameter maps to +Labels+ in the Create a container section of the Docker Remote API and the +--label+ option to docker run." + }, + "docker_security_options" => { + "type" => "array", + "items" => { + "type" => "string", + "description" => "A list of strings to provide custom labels for SELinux and AppArmor multi-level security systems. This field is not valid for containers in tasks using the Fargate launch type. This parameter maps to +SecurityOpt+ in the Create a container section of the Docker Remote API and the +--security-opt+ option to docker run." + } + }, + "health_check" => { + "type" => "object", + "required" => ["command"], + "description" => "The health check command and associated configuration parameters for the container. This parameter maps to +HealthCheck+ in the Create a container section of the Docker Remote API and the +HEALTHCHECK+ parameter of docker run.", + "properties" => { + "command" => { + "type" => "array", + "items" => { + "type" => "string", + "description" => "A string array representing the command that the container runs to determine if it is healthy." + } + }, + "interval" => { + "type" => "integer", + "description" => "The time period in seconds between each health check execution." + }, + "timeout" => { + "type" => "integer", + "description" => "The time period in seconds to wait for a health check to succeed before it is considered a failure." + }, + "retries" => { + "type" => "integer", + "description" => "The number of times to retry a failed health check before the container is considered unhealthy." + }, + "start_period" => { + "type" => "integer", + "description" => "The optional grace period within which to provide containers time to bootstrap before failed health checks count towards the maximum number of retries." + } + } + }, + "environment" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "The environment variables to pass to a container. This parameter maps to +Env+ in the Create a container section of the Docker Remote API and the +--env+ option to docker run.", + "properties" => { + "name" => { + "type" => "string" + }, + "value" => { + "type" => "string" + } + } + } + }, + "resource_requirements" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "Special requirements for this container. As of this writing, +GPU+ is the only valid option.", + "required" => ["type", "value"], + "properties" => { + "type" => { + "type" => "string", + "enum" => ["GPU"], + "description" => "Special requirements for this container. As of this writing, +GPU+ is the only valid option." + }, + "value" => { + "type" => "string", + "description" => "The number of physical GPUs the Amazon ECS container agent will reserve for the container." + } + } + } + }, + "system_controls" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "A list of namespaced kernel parameters to set in the container. This parameter maps to +Sysctls+ in the Create a container section of the Docker Remote API and the +--sysctl+ option to docker run.", + "properties" => { + "namespace" => { + "type" => "string", + "description" => "The namespaced kernel parameter for which to set a +value+." + }, + "value" => { + "type" => "string", + "description" => "The value for the namespaced kernel parameter specified in +namespace+." + } + } + } + }, + "ulimits" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "This parameter maps to +Ulimits+ in the Create a container section of the Docker Remote API and the +--ulimit+ option to docker run.", + "required" => ["name", "soft_limit", "hard_limit"], + "properties" => { + "name" => { + "type" => "string", + "description" => "The ulimit parameter to set.", + "enum" => ["core", "cpu", "data", "fsize", "locks", "memlock", "msgqueue", "nice", "nofile", "nproc", "rss", "rtprio", "rttime", "sigpending", "stack"] + }, + "soft_limit" => { + "type" => "integer", + "description" => "The soft limit for the ulimit type." + }, + "hard_limit" => { + "type" => "integer", + "description" => "The hard limit for the ulimit type." + }, + } + } + }, + "extra_hosts" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "A list of hostnames and IP address mappings to append to the +/etc/hosts+ file on the container. This parameter maps to ExtraHosts in the +Create+ a container section of the Docker Remote API and the +--add-host+ option to docker run.", + "required" => ["hostname", "ip_address"], + "properties" => { + "hostname" => { + "type" => "string" + }, + "ip_address" => { + "type" => "string" + } + } + } + }, + "secrets" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "See https://docs.aws.amazon.com/AmazonECS/latest/developerguide/specifying-sensitive-data.html", + "required" => ["name", "value_from"], + "properties" => { + "name" => { + "type" => "string", + "description" => "The value to set as the environment variable on the container." + }, + "value_from" => { + "type" => "string", + "description" => "The secret to expose to the container." + } + } + } + }, + "depends_on" => { + "type" => "array", + "items" => { + "type" => "object", + "required" => ["container_name", "condition"], + "description" => "The dependencies defined for container startup and shutdown. A container can contain multiple dependencies. When a dependency is defined for container startup, for container shutdown it is reversed.", + "properties" => { + "container_name" => { + "type" => "string" + }, + "condition" => { + "type" => "string", + "enum" => ["START", "COMPLETE", "SUCCESS", "HEALTHY"] + } + } + } + }, + "mount_points" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "The mount points for data volumes in your container. This parameter maps to +Volumes+ in the Create a container section of the Docker Remote API and the +--volume+ option to docker run.", + "properties" => { + "source_volume" => { + "type" => "string", + "description" => "The name of the +volume+ to mount, defined under the +volumes+ section of our parent +container_cluster+ (if the volume is not defined, an ephemeral bind host volume will be allocated)." + }, + "container_path" => { + "type" => "string", + "description" => "The container-side path where this volume must be mounted" + }, + "read_only" => { + "type" => "boolean", + "default" => false, + "description" => "Mount the volume read-only" + } + } + } + }, + "volumes_from" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "Data volumes to mount from another container. This parameter maps to +VolumesFrom+ in the Create a container section of the Docker Remote API and the +--volumes-from+ option to docker run.", + "properties" => { + "source_container" => { + "type" => "string", + "description" => "The name of another container within the same task definition from which to mount volumes." + }, + "read_only" => { + "type" => "boolean", + "default" => false, + "description" => "If this value is +true+, the container has read-only access to the volume." + } + } + } + }, + "repository_credentials" => { + "type" => "object", + "description" => "The Amazon Resource Name (ARN) of a secret containing the private repository credentials.", + "properties" => { + "credentials_parameter" => { + "type" => "string", + # XXX KMS? Secrets Manager? This documentation is vague. + "description" => "The Amazon Resource Name (ARN) of a secret containing the private repository credentials." + } + } + }, + "port_mappings" => { + "type" => "array", + "items" => { + "description" => "Mappings of ports between the container instance and the host instance. This parameter maps to +PortBindings+ in the Create a container section of the Docker Remote API and the +--publish+ option to docker run.", + "type" => "object", + "properties" => { + "container_port" => { + "type" => "integer", + "description" => "The port number on the container that is bound to the user-specified or automatically assigned host port." + }, + "host_port" => { + "type" => "integer", + "description" => "The port number on the container instance to reserve for your container. This should not be specified for Fargate clusters, nor for ECS clusters deployed into VPCs." + }, + "protocol" => { + "type" => "string", + "description" => "The protocol used for the port mapping.", + "enum" => ["tcp", "udp"], + "default" => "tcp" + }, + } + } + }, + "log_configuration" => { + "type" => "object", + "description" => "Where to send container logs. If not specified, Mu will create a CloudWatch Logs output channel. See also: https://docs.aws.amazon.com/sdkforruby/api/Aws/ECS/Types/ContainerDefinition.html#log_configuration-instance_method", + "default" => { + "log_driver" => "awslogs" + }, + "required" => ["log_driver"], + "properties" => { + "log_driver" => { + "type" => "string", + "description" => "Type of logging facility to use for container logs.", + "enum" => ["json-file", "syslog", "journald", "gelf", "fluentd", "awslogs", "splunk"] + }, + "options" => { + "type" => "object", + "description" => "Per-driver configuration options. See also: https://docs.aws.amazon.com/sdkforruby/api/Aws/ECS/Types/ContainerDefinition.html#log_configuration-instance_method" + } + } + }, + "loadbalancers" => { + "type" => "array", + "description" => "Array of loadbalancers to associate with this container servvice See also: https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/ECS/Client.html#create_service-instance_method", + "default" => [], + "items" => { + "description" => "Load Balancers to associate with the container services", + "type" => "object", + "properties" => { + "name" => { + "type" => "string", + "description" => "Name of the loadbalancer to associate" + }, + "container_port" => { + "type" => "integer", + "description" => "container port to map to the loadbalancer" + } + } + } + } + } } } } @@ -520,6 +1527,7 @@ def self.validateConfig(cluster, configurator) cluster['size'] = MU::Cloud::AWS::Server.validateInstanceType(cluster["instance_type"], cluster["region"]) ok = false if cluster['size'].nil? + cluster["flavor"] = "EKS" if cluster["flavor"].match(/^Kubernetes$/i) if cluster["flavor"] == "ECS" and cluster["kubernetes"] and !MU::Cloud::AWS.isGovCloud?(cluster["region"]) cluster["flavor"] = "EKS" @@ -531,25 +1539,134 @@ def self.validateConfig(cluster, configurator) ok = false end - if MU::Cloud::AWS.isGovCloud?(cluster["region"]) and cluster["flavor"] != "ECS" - MU.log "AWS GovCloud does not support #{cluster["flavor"]} yet, just ECS", MU::ERR - ok = false - end - - if cluster["flavor"] == "EKS" and !cluster["vpc"] - if !MU::Cloud::AWS.hosted? - MU.log "EKS cluster #{cluster['name']} must declare a VPC", MU::ERR - ok = false - else + if ["Fargate", "EKS"].include?(cluster["flavor"]) and !cluster["vpc"] + siblings = configurator.haveLitterMate?(nil, "vpcs", has_multiple: true) + if siblings.size == 1 + MU.log "ContainerCluster #{cluster['name']} did not declare a VPC. Inserting into sibling VPC #{siblings[0]['name']}.", MU::WARN cluster["vpc"] = { - "vpc_id" => MU.myVPC, + "name" => siblings[0]['name'], "subnet_pref" => "all_private" } + elsif MU::Cloud::AWS.hosted? and MU::Cloud::AWS.myVPCObj + cluster["vpc"] = { + "id" => MU.myVPC, + "subnet_pref" => "all_private" + } + else + MU.log "ContainerCluster #{cluster['name']} must declare a VPC", MU::ERR + ok = false end + + # Re-insert ourselves with this modification so that our child + # resources get this VPC we just shoved in + if ok and cluster['vpc'] + cluster.delete("#MU_VALIDATED") + return configurator.insertKitten(cluster, "container_clusters", overwrite: true) + end + end + + if cluster["volumes"] + cluster["volumes"].each { |v| + if v["type"] == "docker" + if cluster["flavor"] == "Fargate" + MU.log "ContainerCluster #{cluster['name']}: Docker volumes are not supported in Fargate clusters (volume '#{v['name']}' is not valid)", MU::ERR + ok = false + end + end + } + end + + if cluster["flavor"] != "EKS" and cluster["containers"] + cluster.delete("kubernetes") + created_generic_loggroup = false + cluster['containers'].each { |c| + if c['log_configuration'] and + c['log_configuration']['log_driver'] == "awslogs" and + (!c['log_configuration']['options'] or !c['log_configuration']['options']['awslogs-group']) + + logname = cluster["name"]+"-svclogs" + rolename = cluster["name"]+"-logrole" + c['log_configuration']['options'] ||= {} + c['log_configuration']['options']['awslogs-group'] = logname + c['log_configuration']['options']['awslogs-region'] = cluster["region"] + c['log_configuration']['options']['awslogs-stream-prefix'] ||= c['name'] + if c['mount_points'] + cluster['volumes'] ||= [] + volnames = cluster['volumes'].map { |v| v['name'] } + c['mount_points'].each { |m| + if !volnames.include?(m['source_volume']) + cluster['volumes'] << { + "name" => m['source_volume'], + "type" => "host" + } + end + } + end + + if !created_generic_loggroup + cluster["dependencies"] << { "type" => "log", "name" => logname } + logdesc = { + "name" => logname, + "region" => cluster["region"], + "cloud" => "AWS" + } + configurator.insertKitten(logdesc, "logs") + + if !c['role'] + roledesc = { + "name" => rolename, + "cloud" => "AWS", + "can_assume" => [ + { + "entity_id" => "ecs-tasks.amazonaws.com", + "entity_type" => "service" + } + ], + "policies" => [ + { + "name" => "ECSTaskLogPerms", + "permissions" => [ + "logs:CreateLogStream", + "logs:DescribeLogGroups", + "logs:DescribeLogStreams", + "logs:PutLogEvents" + ], + "import" => [ + "" + ], + "targets" => [ + { + "type" => "log", + "identifier" => logname + } + ] + } + ], + "dependencies" => [{ "type" => "log", "name" => logname }] + } + configurator.insertKitten(roledesc, "roles") + + cluster["dependencies"] << { + "type" => "role", + "name" => rolename + } + end + + created_generic_loggroup = true + end + c['role'] ||= { 'name' => rolename } + end + } + end + + if MU::Cloud::AWS.isGovCloud?(cluster["region"]) and cluster["flavor"] == "EKS" + MU.log "AWS GovCloud does not support #{cluster["flavor"]} yet", MU::ERR + ok = false end + if ["ECS", "EKS"].include?(cluster["flavor"]) - std_ami = getECSImageId(cluster["flavor"], cluster['region']) + std_ami = getStandardImage(cluster["flavor"], cluster['region'], version: cluster['kubernetes']['version'], gpu: cluster['gpu']) cluster["host_image"] ||= std_ami if cluster["host_image"] != std_ami if cluster["flavor"] == "ECS" @@ -563,17 +1680,53 @@ def self.validateConfig(cluster, configurator) end end + if cluster["flavor"] == "Fargate" and !cluster['vpc'] + if MU.myVPC + cluster["vpc"] = { + "vpc_id" => MU.myVPC, + "subnet_pref" => "all_private" + } + MU.log "Fargate cluster #{cluster['name']} did not specify a VPC, inserting into private subnets of #{MU.myVPC}", MU::NOTICE + else + MU.log "Fargate cluster #{cluster['name']} must specify a VPC", MU::ERR + ok = false + end + + end + + fwname = "container_cluster#{cluster['name']}" + cluster['ingress_rules'] ||= [] if ["ECS", "EKS"].include?(cluster["flavor"]) + cluster['ingress_rules'] << { + "sgs" => ["server_pool"+cluster["name"]+"workers"], + "port" => 443, + "proto" => "tcp", + "ingress" => true, + "comment" => "Allow worker nodes to access API" + } + ruleset = configurator.haveLitterMate?(fwname, "firewall_rules") + if ruleset + ruleset["rules"].concat(cluster['ingress_rules']) + ruleset["rules"].uniq! + end + end + + if ["ECS", "EKS"].include?(cluster["flavor"]) + cluster["max_size"] ||= cluster["instance_count"] + cluster["min_size"] ||= cluster["instance_count"] worker_pool = { "name" => cluster["name"]+"workers", + "cloud" => "AWS", + "skipinitialupdates" => (cluster["flavor"] == "EKS"), "credentials" => cluster["credentials"], "region" => cluster['region'], - "min_size" => cluster["instance_count"], - "max_size" => cluster["instance_count"], + "min_size" => cluster["min_size"], + "max_size" => cluster["max_size"], "wait_for_nodes" => cluster["instance_count"], "ssh_user" => cluster["host_ssh_user"], + "role_strip_path" => true, "basis" => { "launch_config" => { "name" => cluster["name"]+"workers", @@ -583,7 +1736,7 @@ def self.validateConfig(cluster, configurator) } if cluster["flavor"] == "EKS" worker_pool["ingress_rules"] = [ - "sgs" => ["container_cluster#{cluster['name']}"], + "sgs" => [fwname], "port_range" => "1-65535" ] worker_pool["application_attributes"] ||= {} @@ -594,7 +1747,8 @@ def self.validateConfig(cluster, configurator) worker_pool["vpc"] = cluster["vpc"].dup worker_pool["vpc"]["subnet_pref"] = cluster["instance_subnet_pref"] worker_pool["vpc"].delete("subnets") - end + end + if cluster["host_image"] worker_pool["basis"]["launch_config"]["image_id"] = cluster["host_image"] end @@ -628,35 +1782,13 @@ def self.validateConfig(cluster, configurator) "name" => cluster["name"]+"workers", "type" => "server_pool", } - elsif cluster["flavor"] == "EKS" - cluster['ingress_rules'] ||= [] - cluster['ingress_rules'] << { - "sgs" => ["server_pool#{cluster['name']}workers"], - "port" => 443 - } - fwname = "container_cluster#{cluster['name']}" - - acl = { - "name" => fwname, - "credentials" => cluster["credentials"], - "rules" => cluster['ingress_rules'], - "region" => cluster['region'], - "optional_tags" => cluster['optional_tags'] - } - acl["tags"] = cluster['tags'] if cluster['tags'] && !cluster['tags'].empty? - acl["vpc"] = cluster['vpc'].dup if cluster['vpc'] - - ok = false if !configurator.insertKitten(acl, "firewall_rules") - cluster["add_firewall_rules"] = [] if cluster["add_firewall_rules"].nil? - cluster["add_firewall_rules"] << {"rule_name" => fwname} - cluster["dependencies"] << { - "name" => fwname, - "type" => "firewall_rule", - } + end + if cluster["flavor"] == "EKS" role = { "name" => cluster["name"]+"controlplane", "credentials" => cluster["credentials"], + "cloud" => "AWS", "can_assume" => [ { "entity_id" => "eks.amazonaws.com", "entity_type" => "service" } ], diff --git a/modules/mu/clouds/aws/database.rb b/modules/mu/clouds/aws/database.rb index 167942e16..7c26e8226 100644 --- a/modules/mu/clouds/aws/database.rb +++ b/modules/mu/clouds/aws/database.rb @@ -19,38 +19,22 @@ class Cloud class AWS # A database as configured in {MU::Config::BasketofKittens::databases} class Database < MU::Cloud::Database - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :cloud_id - attr_reader :config - attr_reader :groomer - - @cloudformation_data = {} - attr_reader :cloudformation_data - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::databases} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - # @mu_name = mu_name ? mu_name : @deploy.getResourceName(@config["name"]) + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @config["groomer"] = MU::Config.defaultGroomer unless @config["groomer"] @groomclass = MU::Groomer.loadGroomer(@config["groomer"]) - if !mu_name.nil? - @mu_name = mu_name - else - @mu_name ||= - if @config and @config['engine'] and @config["engine"].match(/^sqlserver/) - @deploy.getResourceName(@config["name"], max_length: 15) - else - @deploy.getResourceName(@config["name"], max_length: 63) - end + @mu_name ||= + if @config and @config['engine'] and @config["engine"].match(/^sqlserver/) + @deploy.getResourceName(@config["name"], max_length: 15) + else + @deploy.getResourceName(@config["name"], max_length: 63) + end - @mu_name.gsub(/(--|-$)/i, "").gsub(/(_)/, "-").gsub!(/^[^a-z]/i, "") - end + @mu_name.gsub(/(--|-$)/i, "").gsub(/(_)/, "-").gsub!(/^[^a-z]/i, "") end # Called automatically by {MU::Deploy#createResources} @@ -180,27 +164,22 @@ def arn # Locate an existing Database or Databases and return an array containing matching AWS resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @param flags [Hash]: Optional flags - # @return [Array>]: The cloud provider's complete descriptions of matching Databases - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching Databases + def self.find(**args) map = {} - if cloud_id - db = MU::Cloud::AWS::Database.getDatabaseById(cloud_id, region: region, credentials: credentials) - map[cloud_id] = db if db + if args[:cloud_id] + resp = MU::Cloud::AWS::Database.getDatabaseById(args[:cloud_id], region: args[:region], credentials: args[:credentials]) + map[args[:cloud_id]] = resp if resp end - if tag_value - MU::Cloud::AWS.rds(credentials: credentials, region: region).describe_db_instances.db_instances.each { |db| - resp = MU::Cloud::AWS.rds(credentials: credentials, region: region).list_tags_for_resource( - resource_name: MU::Cloud::AWS::Database.getARN(db.db_instance_identifier, "db", "rds", region: region, credentials: credentials) + if args[:tag_value] + MU::Cloud::AWS.rds(credentials: args[:credentials], region: args[:region]).describe_db_instances.db_instances.each { |db| + resp = MU::Cloud::AWS.rds(credentials: args[:credentials], region: args[:region]).list_tags_for_resource( + resource_name: MU::Cloud::AWS::Database.getARN(db.db_instance_identifier, "db", "rds", region: args[:region], credentials: args[:credentials]) ) if resp && resp.tag_list && !resp.tag_list.empty? resp.tag_list.each { |tag| - map[db.db_instance_identifier] = db if tag.key == tag_key and tag.value == tag_value + map[db.db_instance_identifier] = db if tag.key == args[:tag_key] and tag.value == args[:tag_value] } end } @@ -312,6 +291,7 @@ def createDb if %w{existing_snapshot new_snapshot}.include?(@config["creation_style"]) config[:db_snapshot_identifier] = @config["snapshot_id"] + config[:db_cluster_identifier] = @config["cluster_identifier"] if @config["add_cluster_node"] end if @config["creation_style"] == "point_in_time" @@ -384,11 +364,11 @@ def createDb MU::Cloud::AWS.rds(region: @config['region'], credentials: @config['credentials']).wait_until(:db_instance_available, db_instance_identifier: @config['identifier']) do |waiter| # Does create_db_instance implement wait_until_available ? waiter.max_attempts = nil - waiter.before_attempt do |attempts| - MU.log "Waiting for RDS database #{@config['identifier']} to be ready..", MU::NOTICE if attempts % 10 == 0 + waiter.before_attempt do |w_attempts| + MU.log "Waiting for RDS database #{@config['identifier']} to be ready...", MU::NOTICE if w_attempts % 10 == 0 end - waiter.before_wait do |attempts, resp| - throw :success if resp.db_instances.first.db_instance_status == "available" + waiter.before_wait do |w_attempts, r| + throw :success if r.db_instances.first.db_instance_status == "available" throw :failure if Time.now - wait_start_time > 3600 end end @@ -453,11 +433,11 @@ def createDb MU::Cloud::AWS.rds(region: @config['region'], credentials: @config['credentials']).wait_until(:db_instance_available, db_instance_identifier: @config['identifier']) do |waiter| # Does create_db_instance implement wait_until_available ? waiter.max_attempts = nil - waiter.before_attempt do |attempts| - MU.log "Waiting for RDS database #{@config['identifier'] } to be ready..", MU::NOTICE if attempts % 10 == 0 + waiter.before_attempt do |w_attempts| + MU.log "Waiting for RDS database #{@config['identifier'] } to be ready..", MU::NOTICE if w_attempts % 10 == 0 end - waiter.before_wait do |attempts, resp| - throw :success if resp.db_instances.first.db_instance_status == "available" + waiter.before_wait do |w_attempts, r| + throw :success if r.db_instances.first.db_instance_status == "available" throw :failure if Time.now - wait_start_time > 2400 end end @@ -472,6 +452,14 @@ def createDb # Maybe wait for DB instance to be in available state. DB should still be writeable at this state if @config['allow_major_version_upgrade'] && @config["creation_style"] == "new" MU.log "Setting major database version upgrade on #{@config['identifier']}'" + database = MU::Cloud::AWS::Database.getDatabaseById(@config['identifier'], region: @config['region'], credentials: @config['credentials']) + begin + if database.db_instance_status != "available" + sleep 5 + database = MU::Cloud::AWS::Database.getDatabaseById(@config['identifier'], region: @config['region'], credentials: @config['credentials']) + end + end while database.db_instance_status != "available" + MU::Cloud::AWS.rds(region: @config['region'], credentials: @config['credentials']).modify_db_instance( db_instance_identifier: @config['identifier'], apply_immediately: true, @@ -532,6 +520,10 @@ def createDbCluster cluster_config_struct[:use_latest_restorable_time] = true if @config["restore_time"] == "latest" end + if @config['cloudwatch_logs'] + cluster_config_struct[:enable_cloudwatch_logs_exports ] = @config['cloudwatch_logs'] + end + attempts = 0 begin resp = @@ -622,11 +614,8 @@ def createSubnetGroup subnets.each{ |subnet| next if subnet.nil? - if @config["publicly_accessible"] - subnet_ids << subnet.cloud_id if !subnet.private? - elsif !@config["publicly_accessible"] - subnet_ids << subnet.cloud_id if subnet.private? - end + next if @config["publicly_accessible"] and subnet.private? + subnet_ids << subnet.cloud_id } else # If we didn't specify a VPC try to figure out if the account has a default VPC @@ -655,8 +644,8 @@ def createSubnetGroup } @config['vpc'] = { - "vpc_id" => vpc_id, - "subnets" => mu_subnets + "vpc_id" => vpc_id, + "subnets" => mu_subnets } # Default VPC has only public subnets by default so setting publicly_accessible = true @config["publicly_accessible"] = true @@ -798,7 +787,15 @@ def self.getSubnetGroup(subnet_id, region: MU.curRegion) # Called automatically by {MU::Deploy#createResources} def groom - unless @config["create_cluster"] + if @config["create_cluster"] + @config['cluster_node_count'] ||= 1 + if @config['cluster_mode'] == "serverless" + MU::Cloud::AWS.rds(region: @config['region'], credentials: @config['credentials']).modify_current_db_cluster_capacity( + db_cluster_identifier: @cloud_id, + capacity: @config['cluster_node_count'] + ) + end + else database = MU::Cloud::AWS::Database.getDatabaseById(@config['identifier'], region: @config['region'], credentials: @config['credentials']) # Run SQL on deploy @@ -1428,18 +1425,31 @@ def self.schema(config) } } + schema = { "db_parameter_group_parameters" => rds_parameters_primitive, "cluster_parameter_group_parameters" => rds_parameters_primitive, + "parameter_group_family" => { + "type" => "String", + "description" => "An RDS parameter group family. See also https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_WorkingWithParamGroups.html" + }, "cluster_mode" => { "type" => "string", "description" => "The DB engine mode of the DB cluster", "enum" => ["provisioned", "serverless", "parallelquery", "global"], "default" => "provisioned" }, + "cloudwatch_logs" => { + "type" => "array", + "default" => ["error"], + "items" => { + "type" => "string", + "enum" => ["error", "general", "audit", "slow_query"], + } + }, "serverless_scaling" => { "type" => "object", - "descriptions" => "Scaling configuration for a +serverless+ Aurora cluster", + "description" => "Scaling configuration for a +serverless+ Aurora cluster", "default" => { "auto_pause" => false, "min_capacity" => 2, @@ -1505,10 +1515,45 @@ def self.schema(config) def self.validateConfig(db, configurator) ok = true + if db['creation_style'] == "existing_snapshot" and + !db['create_cluster'] and + db['identifier'] and db['identifier'].match(/:cluster-snapshot:/) + MU.log "Database #{db['name']}: Existing snapshot #{db['identifier']} looks like a cluster snapshot, but create_cluster is not set. Add 'create_cluster: true' if you're building an RDS cluster.", MU::ERR + ok = false + end + + pgroup_families = [] + engines = {} + + marker = nil + begin + resp = MU::Cloud::AWS.rds(credentials: db['credentials'], region: db['region']).describe_db_engine_versions(marker: marker) + marker = resp.marker + + if resp and resp.db_engine_versions + resp.db_engine_versions.each { |version| + engines[version.engine] ||= { + "versions" => [], + "families" => [] + } + engines[version.engine]['versions'] << version.engine_version + engines[version.engine]['families'] << version.db_parameter_group_family + + } + engines.keys.each { |engine| + engines[engine]["versions"].uniq! + engines[engine]["families"].uniq! + } + + else + MU.log "Failed to get list of valid RDS engine versions in #{db['region']}, proceeding without proper validation", MU::WARN + end + end while !marker.nil? + if db['create_cluster'] or db['engine'] == "aurora" or db["member_of_cluster"] case db['engine'] when "mysql", "aurora", "aurora-mysql" - if db["engine_version"] == "5.6" or db["cluster_mode"] == "serverless" + if db["engine_version"].match(/^5\.6/) or db["cluster_mode"] == "serverless" db["engine"] = "aurora" else db["engine"] = "aurora-mysql" @@ -1517,10 +1562,44 @@ def self.validateConfig(db, configurator) db["engine"] = "aurora-postgresql" else ok = false - MU.log "Requested a clustered database, but engine #{db['engine']} is not supported for clustering", MU::ERR + MU.log "Database #{db['name']}: Requested a clustered database, but engine #{db['engine']} is not supported for clustering", MU::ERR end end + if db['engine'] == "aurora-postgresql" + db.delete('cloudwatch_logs') + end + + if db['engine'].match(/^aurora/) and !db['create_cluster'] and !db['add_cluster_node'] + MU.log "Database #{db['name']}: #{db['engine']} looks like a cluster engine, but create_cluster is not set. Add 'create_cluster: true' if you're building an RDS cluster.", MU::ERR + ok = false + end + + if engines.size > 0 + if !engines[db['engine']] + MU.log "RDS engine #{db['engine']} is not supported in #{db['region']}", MU::ERR, details: engines.keys.sort + ok = false + else + if db["engine_version"] and + engines[db['engine']]['versions'].size > 0 and + !engines[db['engine']]['versions'].include?(db['engine_version']) and + !engines[db['engine']]['versions'].grep(/^#{Regexp.quote(db["engine_version"])}.+/) + MU.log "RDS engine '#{db['engine']}' version '#{db['engine_version']}' is not supported in #{db['region']}", MU::ERR, details: { "Known-good versions:" => engines[db['engine']]['versions'].uniq.sort } + ok = false + end + if db["parameter_group_family"] and + engines[db['engine']]['families'].size > 0 and + !engines[db['engine']]['families'].include?(db['parameter_group_family']) + MU.log "RDS engine '#{db['engine']}' parameter group family '#{db['parameter_group_family']}' is not supported in #{db['region']}", MU::ERR, details: { "Valid parameter families:" => engines[db['engine']]['families'].uniq.sort } + ok = false + end + end + end + + if db['parameter_group_family'] and pgroup_families.size > 0 and + !pgroup_families.include?(db['parameter_group_family']) + end + db["license_model"] ||= if ["postgres", "postgresql", "aurora-postgresql"].include?(db["engine"]) "postgresql-license" @@ -1605,7 +1684,7 @@ def self.validateConfig(db, configurator) end if db["vpc"] - if db["vpc"]["subnet_pref"] == "all_public" and !db['publicly_accessible'] + if db["vpc"]["subnet_pref"] == "all_public" and !db['publicly_accessible'] and (db["vpc"]['subnets'].nil? or db["vpc"]['subnets'].empty?) MU.log "Setting publicly_accessible to true on database '#{db['name']}', since deploying into public subnets.", MU::WARN db['publicly_accessible'] = true elsif db["vpc"]["subnet_pref"] == "all_private" and db['publicly_accessible'] diff --git a/modules/mu/clouds/aws/dnszone.rb b/modules/mu/clouds/aws/dnszone.rb index 53a531631..431cf1be1 100644 --- a/modules/mu/clouds/aws/dnszone.rb +++ b/modules/mu/clouds/aws/dnszone.rb @@ -19,22 +19,11 @@ class AWS # A DNS Zone as configured in {MU::Config::BasketofKittens::dnszones} class DNSZone < MU::Cloud::DNSZone - @config = nil - attr_reader :mu_name - attr_reader :cloud_id - attr_reader :config - - @cloudformation_data = {} - attr_reader :cloudformation_data - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::dnszones} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - unless @mu_name - @mu_name = mu_name ? mu_name : @deploy.getResourceName(@config["name"]) - end + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + @mu_name ||= @deploy.getResourceName(@config["name"]) MU.setVar("curRegion", @config['region']) if !@config['region'].nil? end @@ -399,8 +388,8 @@ def self.manageRecord(id, name, type, targets: nil, aliases: nil, if !alias_zone.nil? target_zone = "/hostedzone/"+alias_zone if !alias_zone.match(/^\/hostedzone\//) else - MU::Cloud::AWS.listRegions.each { |region| - MU::Cloud::AWS.elb(region: region).describe_load_balancers.load_balancer_descriptions.each { |elb| + MU::Cloud::AWS.listRegions.each { |r| + MU::Cloud::AWS.elb(region: r).describe_load_balancers.load_balancer_descriptions.each { |elb| elb_dns = elb.dns_name.downcase elb_dns.chomp!(".") if target_name == elb_dns @@ -553,7 +542,8 @@ def self.genericMuDNSEntry(name: nil, target: nil, cloudclass: nil, noop: false, if !mu_zone.nil? and !MU.myVPC.nil? subdomain = cloudclass.cfg_name - dns_name = name.downcase+"."+subdomain+"."+MU.myInstanceId + dns_name = name.downcase+"."+subdomain + dns_name += "."+MU.myInstanceId if MU.myInstanceId record_type = "CNAME" record_type = "A" if target.match(/^\d+\.\d+\.\d+\.\d+/) ip = nil @@ -880,36 +870,33 @@ def arn end # Locate an existing DNSZone or DNSZones and return an array containing matching AWS resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. Can also use the domain name, we'll check for both. - # @param region [String]: The cloud provider region - # @param flags [Hash]: Optional flags - # @return [Array>]: The cloud provider's complete descriptions of matching DNSZones - def self.find(cloud_id: nil, deploy_id: MU.deploy_id, region: MU.curRegion, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching DNSZones + def self.find(**args) matches = {} - resp = MU::Cloud::AWS.route53(credentials: credentials).list_hosted_zones( + resp = MU::Cloud::AWS.route53(credentials: args[:credentials]).list_hosted_zones( max_items: 100 ) resp.hosted_zones.each { |zone| - if !cloud_id.nil? and !cloud_id.empty? - if zone.id == cloud_id + if !args[:cloud_id].nil? and !args[:cloud_id].empty? + if zone.id == args[:cloud_id] begin - matches[zone.id] = MU::Cloud::AWS.route53(credentials: credentials).get_hosted_zone(id: zone.id).hosted_zone + matches[zone.id] = MU::Cloud::AWS.route53(credentials: args[:credentials]).get_hosted_zone(id: zone.id).hosted_zone rescue Aws::Route53::Errors::NoSuchHostedZone MU.log "Hosted zone #{zone.id} doesn't exist" end - elsif zone.name == cloud_id or zone.name == cloud_id+"." + elsif zone.name == args[:cloud_id] or zone.name == args[:cloud_id]+"." begin - matches[zone.id] = MU::Cloud::AWS.route53(credentials: credentials).get_hosted_zone(id: zone.id).hosted_zone + matches[zone.id] = MU::Cloud::AWS.route53(credentials: args[:credentials]).get_hosted_zone(id: zone.id).hosted_zone rescue Aws::Route53::Errors::NoSuchHostedZone MU.log "Hosted zone #{zone.id} doesn't exist" end end end - if !deploy_id.nil? and !deploy_id.empty? and zone.config.comment == deploy_id + if !args[:deploy_id].nil? and !args[:deploy_id].empty? and zone.config.comment == args[:deploy_id] begin - matches[zone.id] = MU::Cloud::AWS.route53(credentials: credentials).get_hosted_zone(id: zone.id).hosted_zone + matches[zone.id] = MU::Cloud::AWS.route53(credentials: args[:credentials]).get_hosted_zone(id: zone.id).hosted_zone rescue Aws::Route53::Errors::NoSuchHostedZone MU.log "Hosted zone #{zone.id} doesn't exist" end diff --git a/modules/mu/clouds/aws/endpoint.rb b/modules/mu/clouds/aws/endpoint.rb index a5068fce5..e34db5297 100644 --- a/modules/mu/clouds/aws/endpoint.rb +++ b/modules/mu/clouds/aws/endpoint.rb @@ -3,21 +3,11 @@ class Cloud class AWS # An API as configured in {MU::Config::BasketofKittens::endpoints} class Endpoint < MU::Cloud::Endpoint - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - @cloudformation_data = {} - attr_reader :cloudformation_data - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::endpoints} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"]) end @@ -266,14 +256,11 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent end # Locate an existing API. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching API. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) - if cloud_id - return MU::Cloud::AWS.apig(region: region, credentials: credentials).get_rest_api( - rest_api_id: cloud_id + # @return [Hash]: The cloud provider's complete descriptions of matching API. + def self.find(**args) + if args[:cloud_id] + return MU::Cloud::AWS.apig(region: args[:region], credentials: args[:credentials]).get_rest_api( + rest_api_id: args[:cloud_id] ) end # resp = MU::Cloud::AWS.apig(region: region, credentials: credentials).get_rest_apis diff --git a/modules/mu/clouds/aws/firewall_rule.rb b/modules/mu/clouds/aws/firewall_rule.rb index c91e8b163..43fc0c379 100644 --- a/modules/mu/clouds/aws/firewall_rule.rb +++ b/modules/mu/clouds/aws/firewall_rule.rb @@ -18,30 +18,19 @@ class Cloud class AWS # A firewall ruleset as configured in {MU::Config::BasketofKittens::firewall_rules} class FirewallRule < MU::Cloud::FirewallRule + require "mu/clouds/aws/vpc" - @deploy = nil - @config = nil @admin_sgs = Hash.new @admin_sg_semaphore = Mutex.new - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::firewall_rules} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - if !mu_name.nil? - @mu_name = mu_name + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + if !@vpc.nil? + @mu_name ||= @deploy.getResourceName(@config['name'], need_unique_string: true) else - if !@vpc.nil? - @mu_name = @deploy.getResourceName(@config['name'], need_unique_string: true) - else - @mu_name = @deploy.getResourceName(@config['name']) - end + @mu_name ||= @deploy.getResourceName(@config['name']) end end @@ -86,7 +75,7 @@ def create retry end - MU::MommaCat.createStandardTags(secgroup.group_id, region: @config['region'], credentials: @config['credentials']) + MU::Cloud::AWS.createStandardTags(secgroup.group_id, region: @config['region'], credentials: @config['credentials']) MU::MommaCat.createTag(secgroup.group_id, "Name", groupname, region: @config['region'], credentials: @config['credentials']) if @config['optional_tags'] @@ -106,10 +95,10 @@ def create # XXX the egress logic here is a crude hack, this really needs to be # done at config level setRules( - [], - add_to_self: @config['self_referencing'], - ingress: true, - egress: egress + [], + add_to_self: @config['self_referencing'], + ingress: true, + egress: egress ) MU.log "EC2 Security Group #{groupname} is #{secgroup.group_id}", MU::DEBUG @@ -124,10 +113,10 @@ def groom # XXX the egress logic here is a crude hack, this really needs to be # done at config level setRules( - @config['rules'], - add_to_self: @config['self_referencing'], - ingress: true, - egress: egress + @config['rules'], + add_to_self: @config['self_referencing'], + ingress: true, + egress: egress ) end end @@ -150,14 +139,20 @@ def notify # @param egress [Boolean]: Whether this is an egress ruleset, instead of ingress. # @param port_range [String]: A port range descriptor (e.g. 0-65535). Only valid with udp or tcp. # @return [void] - def addRule(hosts, proto: "tcp", port: nil, egress: false, port_range: "0-65535") + def addRule(hosts, proto: "tcp", port: nil, egress: false, port_range: "0-65535", comment: nil) rule = Hash.new rule["proto"] = proto - if hosts.is_a?(String) - rule["hosts"] = [hosts] - else - rule["hosts"] = hosts - end + sgs = [] + hosts = [hosts] if hosts.is_a?(String) + hosts.each { |h| + if h.match(/^sg-/) + sgs << h + end + } + rule["sgs"] = sgs if sgs.size > 0 + hosts = hosts - sgs + rule["hosts"] = hosts if hosts.size > 0 + if port != nil port = port.to_s if !port.is_a?(String) rule["port"] = port @@ -169,17 +164,32 @@ def addRule(hosts, proto: "tcp", port: nil, egress: false, port_range: "0-65535" begin if egress MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).authorize_security_group_egress( - group_id: @cloud_id, - ip_permissions: ec2_rule + group_id: @cloud_id, + ip_permissions: ec2_rule ) else MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).authorize_security_group_ingress( - group_id: @cloud_id, - ip_permissions: ec2_rule + group_id: @cloud_id, + ip_permissions: ec2_rule ) end rescue Aws::EC2::Errors::InvalidPermissionDuplicate => e MU.log "Attempt to add duplicate rule to #{@cloud_id}", MU::DEBUG, details: ec2_rule + # Ensure that, at least, the description field gets updated on + # existing rules + if comment + if egress + MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).update_security_group_rule_descriptions_egress( + group_id: @cloud_id, + ip_permissions: ec2_rule + ) + else + MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).update_security_group_rule_descriptions_ingress( + group_id: @cloud_id, + ip_permissions: ec2_rule + ) + end + end end end @@ -190,32 +200,27 @@ def arn end # Locate an existing security group or groups and return an array containing matching AWS resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @param flags [Hash]: Optional flags # @return [Array>]: The cloud provider's complete descriptions of matching FirewallRules - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, credentials: nil, flags: {}) + def self.find(**args) - if !cloud_id.nil? and !cloud_id.empty? + if !args[:cloud_id].nil? and !args[:cloud_id].empty? begin - resp = MU::Cloud::AWS.ec2(region: region, credentials: credentials).describe_security_groups(group_ids: [cloud_id]) - return {cloud_id => resp.data.security_groups.first} + resp = MU::Cloud::AWS.ec2(region: args[:region], credentials: args[:credentials]).describe_security_groups(group_ids: [args[:cloud_id]]) + return {args[:cloud_id] => resp.data.security_groups.first} rescue ArgumentError => e - MU.log "Attempting to load #{cloud_id}: #{e.inspect}", MU::WARN, details: caller + MU.log "Attempting to load #{args[:cloud_id]}: #{e.inspect}", MU::WARN, details: caller return {} rescue Aws::EC2::Errors::InvalidGroupNotFound => e - MU.log "Attempting to load #{cloud_id}: #{e.inspect}", MU::DEBUG, details: caller + MU.log "Attempting to load #{args[:cloud_id]}: #{e.inspect}", MU::DEBUG, details: caller return {} end end map = {} - if !tag_key.nil? and !tag_value.nil? - resp = MU::Cloud::AWS.ec2(region: region, credentials: credentials).describe_security_groups( + if !args[:tag_key].nil? and !args[:tag_value].nil? + resp = MU::Cloud::AWS.ec2(region: args[:region], credentials: args[:credentials]).describe_security_groups( filters: [ - {name: "tag:#{tag_key}", values: [tag_value]} + {name: "tag:#{args[:tag_key]}", values: [args[:tag_value]]} ] ) if !resp.nil? @@ -247,19 +252,26 @@ def self.quality # @param region [String]: The cloud provider region # @return [void] def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) - tagfilters = [ + filters = nil + if flags and flags["vpc_id"] + filters = [ + {name: "vpc-id", values: [flags["vpc_id"]]} + ] + else + filters = [ {name: "tag:MU-ID", values: [MU.deploy_id]} - ] - if !ignoremaster - tagfilters << {name: "tag:MU-MASTER-IP", values: [MU.mu_public_ip]} + ] + if !ignoremaster + filters << {name: "tag:MU-MASTER-IP", values: [MU.mu_public_ip]} + end end # Some services create sneaky rogue ENIs which then block removal of # associated security groups. Find them and fry them. - MU::Cloud::AWS::VPC.purge_interfaces(noop, tagfilters, region: region, credentials: credentials) + MU::Cloud::AWS::VPC.purge_interfaces(noop, filters, region: region, credentials: credentials) resp = MU::Cloud::AWS.ec2(credentials: credentials, region: region).describe_security_groups( - filters: tagfilters + filters: filters ) resp.data.security_groups.each { |sg| @@ -339,11 +351,14 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent } resp.data.security_groups.each { |sg| + next if sg.group_name == "default" MU.log "Removing EC2 Security Group #{sg.group_name}" retries = 0 begin MU::Cloud::AWS.ec2(credentials: credentials, region: region).delete_security_group(group_id: sg.group_id) if !noop + rescue Aws::EC2::Errors::CannotDelete => e + MU.log e.message, MU::WARN rescue Aws::EC2::Errors::InvalidGroupNotFound MU.log "EC2 Security Group #{sg.group_name} disappeared before I could delete it!", MU::WARN rescue Aws::EC2::Errors::DependencyViolation, Aws::EC2::Errors::InvalidGroupInUse @@ -466,28 +481,114 @@ def setRules(rules, add_to_self: false, ingress: true, egress: false) end ec2_rules = convertToEc2(rules) + ext_permissions = MU.structToHash(cloud_desc.ip_permissions) + + # Purge any old rules that we're sure we created (check the comment) + # but which are no longer configured. + ext_permissions.each { |ext_rule| + haverule = false + ec2_rules.each { |rule| + if rule[:from_port] == ext_rule[:from_port] and + rule[:to_port] == ext_rule[:to_port] and + rule[:ip_protocol] == ext_rule[:ip_protocol] + haverule = true + break + end + } + next if haverule + + mu_comments = false + (ext_rule[:user_id_group_pairs] + ext_rule[:ip_ranges]).each { |entry| + if entry[:description] == "Added by Mu" + mu_comments = true + else + mu_comments = false + break + end + } + + if mu_comments + ext_rule.keys.each { |k| + if ext_rule[k].nil? or ext_rule[k] == [] + ext_rule.delete(k) + end + } + MU.log "Removing unconfigured rule in #{@mu_name}", MU::WARN, details: ext_rule + MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).revoke_security_group_ingress( + group_id: @cloud_id, + ip_permissions: [ext_rule] + ) + end + + } # Creating an empty security group is ok, so don't freak out if we get # a null rule list. if !ec2_rules.nil? ec2_rules.uniq! - MU.log "Setting rules in Security Group #{@mu_name} (#{@cloud_id})", details: ec2_rules retries = 0 - if rules != nil - MU.log "Rules for EC2 Security Group #{@mu_name} (#{@cloud_id}): #{ec2_rules}", MU::DEBUG + ec2_rules.each { |rule| + haverule = nil + different = false + ext_permissions.each { |ext_rule| + if rule[:from_port] == ext_rule[:from_port] and + rule[:to_port] == ext_rule[:to_port] and + rule[:ip_protocol] == ext_rule[:ip_protocol] + haverule = ext_rule + ext_rule.keys.each { |k| + if ext_rule[k].nil? or ext_rule[k] == [] + haverule.delete(k) + end + different = true if rule[k] != ext_rule[k] + } + break + end + } + if haverule and !different + MU.log "Security Group rule already up-to-date in #{@mu_name}", MU::DEBUG, details: rule + next + end + + MU.log "Setting #{ingress ? "ingress" : "egress"} rule in Security Group #{@mu_name} (#{@cloud_id})", MU::NOTICE, details: rule begin + if ingress - MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).authorize_security_group_ingress( + if haverule + begin + MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).revoke_security_group_ingress( + group_id: @cloud_id, + ip_permissions: [haverule] + ) + rescue Aws::EC2::Errors::InvalidPermissionNotFound => e + end + end + begin + MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).authorize_security_group_ingress( group_id: @cloud_id, - ip_permissions: ec2_rules - ) + ip_permissions: [rule] + ) + rescue Aws::EC2::Errors::InvalidParameterCombination => e + MU.log "FirewallRule #{@mu_name} had a bogus rule: #{e.message}", MU::ERR, details: rule + raise e + end end + if egress + if haverule + begin + MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).revoke_security_group_egress( + group_id: @cloud_id, + ip_permissions: [haverule] + ) + rescue Aws::EC2::Errors::InvalidPermissionNotFound => e + end + end MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).authorize_security_group_egress( - group_id: @cloud_id, - ip_permissions: ec2_rules + group_id: @cloud_id, + ip_permissions: [rule] ) end + rescue Aws::EC2::Errors::InvalidGroupNotFound => e MU.log "#{@mu_name} (#{@cloud_id}) does not yet exist", MU::WARN retries = retries + 1 @@ -498,9 +599,9 @@ def setRules(rules, add_to_self: false, ingress: true, egress: false) raise MuError, "#{@mu_name} does not exist", e.backtrace end rescue Aws::EC2::Errors::InvalidPermissionDuplicate => e - MU.log "Attempt to add duplicate rule to #{@mu_name}", MU::DEBUG, details: ec2_rules + MU.log "Attempt to add duplicate rule to #{@mu_name}", MU::DEBUG, details: rule end - end + } end end @@ -516,6 +617,8 @@ def convertToEc2(rules) rules.each { |rule| ec2_rule = {} + rule["comment"] ||= "Added by Mu" + rule['proto'] ||= "tcp" ec2_rule[:ip_protocol] = rule['proto'] @@ -542,9 +645,9 @@ def convertToEc2(rules) end if (!defined? rule['hosts'] or !rule['hosts'].is_a?(Array)) and - (!defined? rule['sgs'] or !rule['sgs'].is_a?(Array)) and - (!defined? rule['lbs'] or !rule['lbs'].is_a?(Array)) - raise MuError, "One of 'hosts', 'sgs', or 'lbs' in rules provided to createEc2SG must be an array." + (!defined? rule['sgs'] or !rule['sgs'].is_a?(Array)) and + (!defined? rule['lbs'] or !rule['lbs'].is_a?(Array)) + rule['hosts'] = ["0.0.0.0/0"] end ec2_rule[:ip_ranges] = [] ec2_rule[:user_id_group_pairs] = [] @@ -554,7 +657,7 @@ def convertToEc2(rules) rule['hosts'].each { |cidr| next if cidr.nil? # XXX where is that coming from? cidr = cidr + "/32" if cidr.match(/^\d+\.\d+\.\d+\.\d+$/) - ec2_rule[:ip_ranges] << {cidr_ip: cidr} + ec2_rule[:ip_ranges] << {cidr_ip: cidr, description: rule['comment']} } end @@ -601,25 +704,25 @@ def convertToEc2(rules) rule['sgs'].uniq! rule['sgs'].each { |sg_name| dependencies # Make sure our cache is fresh - if sg_name == @config['name'] - sg = self + sg = @deploy.findLitterMate(type: "firewall_rule", name: sg_name) if @deploy + sg ||= if sg_name == @config['name'] + self elsif @dependencies.has_key?("firewall_rule") and @dependencies["firewall_rule"].has_key?(sg_name) - sg = @dependencies["firewall_rule"][sg_name] - else - if sg_name.match(/^sg-/) - found_sgs = MU::MommaCat.findStray("AWS", "firewall_rule", cloud_id: sg_name, region: @config['region'], calling_deploy: @deploy, dummy_ok: true) - else - found_sgs = MU::MommaCat.findStray("AWS", "firewall_rule", name: sg_name, region: @config['region'], deploy_id: MU.deploy_id, calling_deploy: @deploy) - end - if found_sgs.nil? or found_sgs.size == 0 - raise MuError, "Attempted to reference non-existent Security Group #{sg_name} while building #{@mu_name}" - end - sg = found_sgs.first + @dependencies["firewall_rule"][sg_name] + elsif sg_name.match(/^sg-/) + found_sgs = MU::MommaCat.findStray("AWS", "firewall_rule", cloud_id: sg_name, region: @config['region'], calling_deploy: @deploy, dummy_ok: true) + found_sgs.first if found_sgs + end + + if sg.nil? + raise MuError, "FirewallRule #{@config['name']} referenced security group '#{sg_name}' in a rule, but I can't find it anywhere!" end + ec2_rule[:user_id_group_pairs] << { user_id: MU.account_number, - group_id: sg.cloud_id + group_id: sg.cloud_id, + description: rule['comment'] } } end diff --git a/modules/mu/clouds/aws/folder.rb b/modules/mu/clouds/aws/folder.rb index e9d85e87c..33f0233f1 100644 --- a/modules/mu/clouds/aws/folder.rb +++ b/modules/mu/clouds/aws/folder.rb @@ -17,18 +17,11 @@ class Cloud class AWS # A log as configured in {MU::Config::BasketofKittens::logs} class Folder < MU::Cloud::Folder - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::logs} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"]) end @@ -70,16 +63,13 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent end # Locate an existing AWS organization. If no identifying parameters are specified, this will return a description of the Organization which owns the account for our credentials. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching log group. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching log group. + def self.find(**args) found = nil - if cloud_id + if args[:cloud_id] else - resp = MU::Cloud::AWS.orgs(credentials: credentials).describe_organization + resp = MU::Cloud::AWS.orgs(credentials: args[:credentials]).describe_organization found ||= {} found[resp.organization.id] = resp.organization end diff --git a/modules/mu/clouds/aws/function.rb b/modules/mu/clouds/aws/function.rb index 4481b8d49..d5bbcfdff 100644 --- a/modules/mu/clouds/aws/function.rb +++ b/modules/mu/clouds/aws/function.rb @@ -17,21 +17,11 @@ class Cloud class AWS # A function as configured in {MU::Config::BasketofKittens::functions} class Function < MU::Cloud::Function - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - @cloudformation_data = {} - attr_reader :cloudformation_data - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::functions} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"]) end @@ -245,7 +235,7 @@ def adjust_trigger(trig_type, trig_arn, func_arn, func_id=nil, protocol='lambda' } ] }) - when 'apigateway' +# when 'apigateway' # XXX this is actually happening in ::Endpoint... maybe... # MU.log "Creation of API Gateway integrations not yet implemented, you'll have to do this manually", MU::WARN, details: "(because we'll basically have to implement all of APIG for this)" end @@ -302,17 +292,14 @@ def arn end # Locate an existing function. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching function. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching function. + def self.find(**args) matches = {} - if !cloud_id.nil? - all_functions = MU::Cloud::AWS.lambda(region: region, credentials: credentials).list_functions + if !args[:cloud_id].nil? + all_functions = MU::Cloud::AWS.lambda(region: args[:region], credentials: args[:credentials]).list_functions all_functions.functions.each do |x| - if x.function_name == cloud_id + if x.function_name == args[:cloud_id] matches[x.function_name] = x break end diff --git a/modules/mu/clouds/aws/group.rb b/modules/mu/clouds/aws/group.rb index e72e1817d..9dcb72063 100644 --- a/modules/mu/clouds/aws/group.rb +++ b/modules/mu/clouds/aws/group.rb @@ -17,19 +17,11 @@ class Cloud class AWS # A group as configured in {MU::Config::BasketofKittens::groups} class Group < MU::Cloud::Group - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::groups} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= if @config['unique_name'] @deploy.getResourceName(@config["name"]) else @@ -167,18 +159,15 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent end # Locate an existing group group. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching group group. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching group group. + def self.find(**args) found = nil begin - resp = MU::Cloud::AWS.iam(credentials: credentials).get_group( - group_name: cloud_id + resp = MU::Cloud::AWS.iam(credentials: args[:credentials]).get_group( + group_name: args[:cloud_id] ) found ||= {} - found[cloud_id] = resp + found[args[:cloud_id]] = resp rescue Aws::IAM::Errors::NoSuchEntity end found diff --git a/modules/mu/clouds/aws/habitat.rb b/modules/mu/clouds/aws/habitat.rb index 9853e4a6d..075225baf 100644 --- a/modules/mu/clouds/aws/habitat.rb +++ b/modules/mu/clouds/aws/habitat.rb @@ -17,19 +17,11 @@ class Cloud class AWS # Creates an AWS account as configured in {MU::Config::BasketofKittens::habitats} class Habitat < MU::Cloud::Habitat - @deploy = nil - @config = nil - - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::habitats} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"], max_length: 63) end @@ -115,11 +107,8 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent end # Locate an existing account - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching account - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching account + def self.find(**args) {} end @@ -137,6 +126,12 @@ def self.schema(config) [toplevel_required, schema] end + # @param account_number [String] + # @return [Boolean] + def self.isLive?(account_number, credentials = nil) + true + end + # Figure out what account we're calling from, and then figure out if # it's the organization's master account- the only place from which # we can create accounts, amongst other things. diff --git a/modules/mu/clouds/aws/loadbalancer.rb b/modules/mu/clouds/aws/loadbalancer.rb index fa118f903..edad24c84 100644 --- a/modules/mu/clouds/aws/loadbalancer.rb +++ b/modules/mu/clouds/aws/loadbalancer.rb @@ -18,30 +18,15 @@ class AWS # A load balancer as configured in {MU::Config::BasketofKittens::loadbalancers} class LoadBalancer < MU::Cloud::LoadBalancer - @deploy = nil @lb = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id attr_reader :targetgroups - @cloudformation_data = {} - attr_reader :cloudformation_data - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::loadbalancers} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - if !mu_name.nil? - @mu_name = mu_name - elsif @config['scrub_mu_isms'] - @mu_name = @config['name'] - else - @mu_name = @deploy.getResourceName(@config["name"], max_length: 32, need_unique_string: true) - @mu_name.gsub!(/[^\-a-z0-9]/i, "-") # AWS ELB naming rules - end + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + @mu_name ||= @deploy.getResourceName(@config["name"], max_length: 32, need_unique_string: true) + @mu_name.gsub!(/[^\-a-z0-9]/i, "-") # AWS ELB naming rules end # Called automatically by {MU::Deploy#createResources} @@ -208,8 +193,12 @@ def create :name => tg_name, :protocol => tg['proto'], :vpc_id => @vpc.cloud_id, - :port => tg['port'] + :port => tg['port'], + :target_type => 'instance' } + if tg['target_type'] && tg['target_type'] != 'instance' + tg_descriptor[:target_type] = tg['target_type'] + end if tg['httpcode'] tg_descriptor[:matcher] = { :http_code => tg['httpcode'] @@ -644,7 +633,9 @@ def self.quality # @param region [String]: The cloud provider region # @return [void] def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) - raise MuError, "Can't touch ELBs without MU-ID" if MU.deploy_id.nil? or MU.deploy_id.empty? + if (MU.deploy_id.nil? or MU.deploy_id.empty?) and (!flags or !flags["vpc_id"]) + raise MuError, "Can't touch ELBs without MU-ID or vpc_id flag" + end # Check for tags matching the current deploy identifier on an elb or # elb2 resource. @@ -692,19 +683,36 @@ def self.checkForTagMatch(arn, region, ignoremaster, credentials, classic = fals begin tags = [] matched = false - if classic - matched = self.checkForTagMatch(lb.load_balancer_name, region, ignoremaster, credentials, classic) + if flags and flags['vpc_id'] + matched = true if lb.vpc_id == flags['vpc_id'] else - matched = self.checkForTagMatch(lb.load_balancer_arn, region, ignoremaster, credentials, classic) + if classic + matched = self.checkForTagMatch(lb.load_balancer_name, region, ignoremaster, credentials, classic) + else + matched = self.checkForTagMatch(lb.load_balancer_arn, region, ignoremaster, credentials, classic) + end end if matched if !MU::Cloud::AWS.isGovCloud? MU::Cloud::AWS::DNSZone.genericMuDNSEntry(name: lb.load_balancer_name, target: lb.dns_name, cloudclass: MU::Cloud::LoadBalancer, delete: true) if !noop end - MU.log "Removing Elastic Load Balancer #{lb.load_balancer_name}" if classic - MU::Cloud::AWS.elb(credentials: credentials, region: region).delete_load_balancer(load_balancer_name: lb.load_balancer_name) if !noop + MU.log "Removing Elastic Load Balancer #{lb.load_balancer_name}" + if !noop + MU::Cloud::AWS.elb(credentials: credentials, region: region).delete_load_balancer(load_balancer_name: lb.load_balancer_name) + stillhere = true + begin + ext_check = MU::Cloud::AWS.elb(credentials: credentials, region: region).describe_load_balancers(load_balancer_names: [lb.load_balancer_name]) + if !ext_check or + !ext_check.load_balancer_descriptions or + !ext_check.load_balancer_descriptions[0] + sleep 3 + else stillhere = false + end + end while stillhere + end else + MU.log "Removing Application Load Balancer #{lb.load_balancer_name}" MU::Cloud::AWS.elb2(credentials: credentials, region: region).describe_listeners( load_balancer_arn: lb.load_balancer_arn ).listeners.each { |l| @@ -765,6 +773,10 @@ def self.schema(config) "proto" => { "type" => "string", "enum" => ["HTTP", "HTTPS", "TCP", "SSL"], + }, + "target_type " => { + "type" => "string", + "enum" => ["instance", "ip", "lambda"], } } } @@ -840,43 +852,39 @@ def self.validateConfig(lb, configurator) end # Locate an existing LoadBalancer or LoadBalancers and return an array containing matching AWS resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @param flags [Hash]: Optional flags - # @return [Array>]: The cloud provider's complete descriptions of matching LoadBalancers - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, credentials: nil, flags: {}) - classic = flags['classic'] ? true : false + # @return [Hash]: The cloud provider's complete descriptions of matching LoadBalancers + def self.find(**args) + args[:flags] ||= {} + classic = args[:flags]['classic'] ? true : false matches = {} list = {} arn2name = {} resp = nil if classic - resp = MU::Cloud::AWS.elb(region: region, credentials: credentials).describe_load_balancers().load_balancer_descriptions + resp = MU::Cloud::AWS.elb(region: args[:region], credentials: args[:credentials]).describe_load_balancers().load_balancer_descriptions else - resp = MU::Cloud::AWS.elb2(region: region, credentials: credentials).describe_load_balancers().load_balancers + resp = MU::Cloud::AWS.elb2(region: args[:region], credentials: args[:credentials]).describe_load_balancers().load_balancers end resp.each { |lb| list[lb.load_balancer_name] = lb arn2name[lb.load_balancer_arn] = lb.load_balancer_name if !classic - if !cloud_id.nil? and lb.load_balancer_name == cloud_id - matches[cloud_id] = lb + if !args[:cloud_id].nil? and lb.load_balancer_name == args[:cloud_id] + matches[args[:cloud_id]] = lb end } return matches if matches.size > 0 - if !tag_key.nil? and !tag_value.nil? and !tag_key.empty? and list.size > 0 + if !args[:tag_key].nil? and !args[:tag_value].nil? and !args[:tag_key].empty? and list.size > 0 tag_descriptions = nil if classic - tag_descriptions = MU::Cloud::AWS.elb(region: region, credentials: credentials).describe_tags( + tag_descriptions = MU::Cloud::AWS.elb(region: args[:region], credentials: args[:credentials]).describe_tags( load_balancer_names: list.keys ).tag_descriptions else - tag_descriptions = MU::Cloud::AWS.elb2(region: region, credentials: credentials).describe_tags( + tag_descriptions = MU::Cloud::AWS.elb2(region: args[:region], credentials: args[:credentials]).describe_tags( resource_arns: list.values.map { |l| l.load_balancer_arn } ).tag_descriptions end @@ -884,7 +892,7 @@ def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: n tag_descriptions.each { |lb| lb_name = classic ? lb.load_balancer_name : arn2name[lb.resource_arn] lb.tags.each { |tag| - if tag.key == tag_key and tag.value == tag_value + if tag.key == args[:tag_key] and tag.value == args[:tag_value] matches[lb_name] = list[lb_name] end } diff --git a/modules/mu/clouds/aws/log.rb b/modules/mu/clouds/aws/log.rb index e88a8f7d4..79e6045c1 100644 --- a/modules/mu/clouds/aws/log.rb +++ b/modules/mu/clouds/aws/log.rb @@ -17,19 +17,11 @@ class Cloud class AWS # A logging facility as configured in {MU::Config::BasketofKittens::logs} class Log < MU::Cloud::Log - @deploy = nil - @config = nil - - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::logs} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"]) end @@ -191,13 +183,14 @@ def self.allowService(service, log_arn, region = MU.myRegion) # Return the cloud descriptor for the Log Group def cloud_desc - MU::Cloud::AWS::Log.find(cloud_id: @cloud_id).values.first + found = MU::Cloud::AWS::Log.find(cloud_id: @cloud_id) + found ? found.values.first : nil end # Canonical Amazon Resource Number for this resource # @return [String] def arn - cloud_desc.arn + cloud_desc ? cloud_desc.arn : nil end # Return the metadata for this log configuration @@ -270,18 +263,15 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent end # Locate an existing log group. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching log group. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching log group. + def self.find(**args) found = nil - if !cloud_id.nil? and !cloud_id.match(/^arn:/i) + if !args[:cloud_id].nil? and !args[:cloud_id].match(/^arn:/i) found ||= {} - found[cloud_id] = MU::Cloud::AWS::Log.getLogGroupByName(cloud_id, region: region, credentials: nil) + found[args[:cloud_id]] = MU::Cloud::AWS::Log.getLogGroupByName(args[:cloud_id], region: args[:region], credentials: args[:credentials]) else - resp = MU::Cloud::AWS.cloudwatchlogs(region: region, credentials: credentials).describe_log_groups.log_groups.each { |group| - if group.arn == cloud_id or group.arn.sub(/:\*$/, "") == cloud_id + resp = MU::Cloud::AWS.cloudwatchlogs(region: args[:region], credentials: args[:credentials]).describe_log_groups.log_groups.each { |group| + if group.arn == args[:cloud_id] or group.arn.sub(/:\*$/, "") == args[:cloud_id] found ||= {} found[group.log_group_name] = group break diff --git a/modules/mu/clouds/aws/msg_queue.rb b/modules/mu/clouds/aws/msg_queue.rb index 0b54da42f..7d05885a3 100644 --- a/modules/mu/clouds/aws/msg_queue.rb +++ b/modules/mu/clouds/aws/msg_queue.rb @@ -17,27 +17,12 @@ class Cloud class AWS # A MsgQueue as configured in {MU::Config::BasketofKittens::msg_queues} class MsgQueue < MU::Cloud::MsgQueue - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - @cloudformation_data = {} - attr_reader :cloudformation_data - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::msg_queues} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - if mu_name - @mu_name = mu_name - cloud_desc if !@cloud_id - else - @mu_name ||= @deploy.getResourceName(@config["name"]) - end + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + @mu_name ||= @deploy.getResourceName(@config["name"]) end # Called automatically by {MU::Deploy#createResources} @@ -62,9 +47,9 @@ def groom tagQueue cur_attrs = notify - if cur_attrs["Policy"] - MU.log "FECK", MU::WARN, details: JSON.parse(cur_attrs["Policy"]).to_yaml - end +# if cur_attrs["Policy"] +# MU.log "FECK", MU::WARN, details: JSON.parse(cur_attrs["Policy"]).to_yaml +# end new_attrs = genQueueAttrs changed = false @@ -168,47 +153,44 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent end # Locate an existing msg_queue. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags # @return [Hash]: AWS doesn't return anything but the SQS URL, so supplement with attributes - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) - flags['account'] ||= MU.account_number - return nil if !cloud_id + def self.find(**args) + args[:flags] ||= {} + args[:flags]['account'] ||= MU.account_number + return nil if !args[:cloud_id] # If it's a URL, make sure it's good begin - if cloud_id.match(/^https?:/i) - resp = MU::Cloud::AWS.sqs(region: region, credentials: credentials).get_queue_attributes( - queue_url: cloud_id, + if args[:cloud_id].match(/^https?:/i) + resp = MU::Cloud::AWS.sqs(region: args[:region], credentials: args[:credentials]).get_queue_attributes( + queue_url: args[:cloud_id], attribute_names: ["All"] ) if resp and resp.attributes desc = resp.attributes.dup - desc["Url"] = cloud_id + desc["Url"] = args[:cloud_id] return desc end else # If it's a plain queue name, resolve it to a URL - resp = MU::Cloud::AWS.sqs(region: region, credentials: credentials).get_queue_url( - queue_name: cloud_id, - queue_owner_aws_account_id: flags['account'] + resp = MU::Cloud::AWS.sqs(region: args[:region], credentials: args[:credentials]).get_queue_url( + queue_name: args[:cloud_id], + queue_owner_aws_account_id: args[:flags]['account'] ) - cloud_id = resp.queue_url if resp and resp.queue_url + args[:cloud_id] = resp.queue_url if resp and resp.queue_url end rescue ::Aws::SQS::Errors::NonExistentQueue => e end # Go fetch its attributes - if cloud_id - resp = MU::Cloud::AWS.sqs(region: region, credentials: credentials).get_queue_attributes( - queue_url: cloud_id, + if args[:cloud_id] + resp = MU::Cloud::AWS.sqs(region: args[:region], credentials: args[:credentials]).get_queue_attributes( + queue_url: args[:cloud_id], attribute_names: ["All"] ) if resp and resp.attributes desc = resp.attributes.dup - desc["Url"] = cloud_id -MU.log "RETURNING FROM FIND ON #{cloud_id}", MU::WARN, details: caller + desc["Url"] = args[:cloud_id] return desc end end diff --git a/modules/mu/clouds/aws/nosqldb.rb b/modules/mu/clouds/aws/nosqldb.rb index d83ebd353..1e75b976c 100644 --- a/modules/mu/clouds/aws/nosqldb.rb +++ b/modules/mu/clouds/aws/nosqldb.rb @@ -17,22 +17,14 @@ class Cloud class AWS # Support for AWS DynamoDB class NoSQLDB < MU::Cloud::NoSQLDB - @deploy = nil - @config = nil @@region_cache = {} @@region_cache_semaphore = Mutex.new - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::logs} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"]) end @@ -106,7 +98,7 @@ def create end } end -pp params + MU.log "Creating DynamoDB table #{@mu_name}", details: params resp = MU::Cloud::AWS.dynamo(credentials: @config['credentials'], region: @config['region']).create_table(params) @@ -177,16 +169,25 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent resp.table_names.each { |table| desc = MU::Cloud::AWS.dynamo(credentials: credentials, region: region).describe_table(table_name: table).table next if desc.table_status == "DELETING" - tags = MU::Cloud::AWS.dynamo(credentials: credentials, region: region).list_tags_of_resource(resource_arn: desc.table_arn) - if tags and tags.tags - tags.tags.each { |tag| - if tag.key == "MU-ID" and tag.value == MU.deploy_id - MU.log "Deleting DynamoDB table #{desc.table_name}" - if !noop - MU::Cloud::AWS.dynamo(credentials: credentials, region: region).delete_table(table_name: desc.table_name) + if desc.table_status == "CREATING" + begin + desc = MU::Cloud::AWS.dynamo(credentials: credentials, region: region).describe_table(table_name: table).table + sleep 1 + end while desc.table_status == "CREATING" + end + begin + tags = MU::Cloud::AWS.dynamo(credentials: credentials, region: region).list_tags_of_resource(resource_arn: desc.table_arn) + if tags and tags.tags + tags.tags.each { |tag| + if tag.key == "MU-ID" and tag.value == MU.deploy_id + MU.log "Deleting DynamoDB table #{desc.table_name}" + if !noop + MU::Cloud::AWS.dynamo(credentials: credentials, region: region).delete_table(table_name: desc.table_name) + end end - end - } + } + end + rescue Aws::DynamoDB::Errors::ResourceNotFoundException => e end } @@ -208,15 +209,15 @@ def notify end # Locate an existing DynamoDB table - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching bucket. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching bucket. + def self.find(**args) found = {} - if cloud_id - resp = MU::Cloud::AWS.dynamo(credentials: credentials, region: region).describe_table(table_name: cloud_id) - found[cloud_id] = resp.table if resp and resp.table + if args[:cloud_id] + begin + resp = MU::Cloud::AWS.dynamo(credentials: args[:credentials], region: args[:region]).describe_table(table_name: args[:cloud_id]) + rescue ::Aws::DynamoDB::Errors::ResourceNotFoundException + end + found[args[:cloud_id]] = resp.table if resp and resp.table end found end diff --git a/modules/mu/clouds/aws/notifier.rb b/modules/mu/clouds/aws/notifier.rb index c07cf1c5a..3b3b85f9f 100644 --- a/modules/mu/clouds/aws/notifier.rb +++ b/modules/mu/clouds/aws/notifier.rb @@ -17,19 +17,11 @@ class Cloud class AWS # Support for AWS SNS class Notifier < MU::Cloud::Notifier - @deploy = nil - @config = nil - - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::logs} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"]) end @@ -87,6 +79,7 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent # Canonical Amazon Resource Number for this resource # @return [String] def arn + @cloud_id ||= @mu_name "arn:"+(MU::Cloud::AWS.isGovCloud?(@config["region"]) ? "aws-us-gov" : "aws")+":sns:"+@config['region']+":"+MU::Cloud::AWS.credToAcct(@config['credentials'])+":"+@cloud_id end @@ -98,16 +91,13 @@ def notify end # Locate an existing notifier. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching notifier. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching notifier. + def self.find(**args) found = {} - if cloud_id - arn = "arn:"+(MU::Cloud::AWS.isGovCloud?(region) ? "aws-us-gov" : "aws")+":sns:"+region+":"+MU::Cloud::AWS.credToAcct(credentials)+":"+cloud_id - desc = MU::Cloud::AWS.sns(region: region, credentials: credentials).get_topic_attributes(topic_arn: arn).attributes - found[cloud_id] = desc if desc + if args[:cloud_id] + arn = "arn:"+(MU::Cloud::AWS.isGovCloud?(args[:region]) ? "aws-us-gov" : "aws")+":sns:"+args[:region]+":"+MU::Cloud::AWS.credToAcct(args[:credentials])+":"+args[:cloud_id] + desc = MU::Cloud::AWS.sns(region: args[:region], credentials: args[:credentials]).get_topic_attributes(topic_arn: arn).attributes + found[args[:cloud_id]] = desc if desc end found end diff --git a/modules/mu/clouds/aws/role.rb b/modules/mu/clouds/aws/role.rb index 85df28b29..b0ec78058 100644 --- a/modules/mu/clouds/aws/role.rb +++ b/modules/mu/clouds/aws/role.rb @@ -17,19 +17,11 @@ class Cloud class AWS # A user as configured in {MU::Config::BasketofKittens::roles} class Role < MU::Cloud::Role - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::roles} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - @mu_name = mu_name + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"]) end @@ -54,8 +46,10 @@ def create if !@config['bare_policies'] MU.log "Creating IAM role #{@mu_name}" + @cloud_id = @mu_name + path = @config['strip_path'] ? nil : "/"+@deploy.deploy_id+"/" resp = MU::Cloud::AWS.iam(credentials: @config['credentials']).create_role( - path: "/"+@deploy.deploy_id+"/", + path: nil, role_name: @mu_name, description: "Generated by Mu", assume_role_policy_document: gen_role_policy_doc, @@ -128,8 +122,18 @@ def groom policy_arn: arn, version_id: desc.policy.default_version_id ) - if version.policy_version.document != URI.encode(JSON.generate(policy.values.first), /[^a-z0-9\-]/i) - MU.log "Updating IAM policy #{policy_name}", MU::NOTICE, details: policy.values.first + + if version.policy_version.document != URI.encode(JSON.generate(policy.values.first))#, /[^a-z0-9\-]/i) + # Special exception- we don't want to overwrite extra rules + # in MuSecrets policies, because our siblings might have + # (will have) injected those and they should stay. + if policy.size == 1 and policy["MuSecrets"] + ext = JSON.parse(URI.decode(version.policy_version.document)) + if (ext["Statement"][0]["Resource"] & policy["MuSecrets"]["Statement"][0]["Resource"]).sort == policy["MuSecrets"]["Statement"][0]["Resource"].sort + next + end + end + MU.log "Updating IAM policy #{policy_name}", MU::NOTICE, details: policy update_policy(arn, policy.values.first) MU::Cloud::AWS.iam(credentials: @config['credentials']).get_policy(policy_arn: arn) else @@ -196,6 +200,7 @@ def cloud_desc end end + desc['cloud_id'] ||= @cloud_id desc end @@ -216,6 +221,7 @@ def injectPolicyTargets(policy, targets, mu_type = nil) end my_policies = cloud_desc["policies"] + my_policies ||= [] my_policies.each { |p| if p.policy_name == policy @@ -265,40 +271,55 @@ def self.purgePolicy(policy_arn, credentials) policy_arn: policy_arn ) attachments.policy_users.each { |u| - MU::Cloud::AWS.iam(credentials: credentials).detach_user_policy( - user_name: u.user_name, - policy_arn: policy_arn - ) + begin + MU::Cloud::AWS.iam(credentials: credentials).detach_user_policy( + user_name: u.user_name, + policy_arn: policy_arn + ) + rescue ::Aws::IAM::Errors::NoSuchEntity + end } attachments.policy_groups.each { |g| - MU::Cloud::AWS.iam(credentials: credentials).detach_group_policy( - group_name: g.group_name, - policy_arn: policy_arn - ) + begin + MU::Cloud::AWS.iam(credentials: credentials).detach_group_policy( + group_name: g.group_name, + policy_arn: policy_arn + ) + rescue ::Aws::IAM::Errors::NoSuchEntity + end } attachments.policy_roles.each { |r| - MU::Cloud::AWS.iam(credentials: credentials).detach_role_policy( - role_name: r.role_name, - policy_arn: policy_arn - ) + begin + MU::Cloud::AWS.iam(credentials: credentials).detach_role_policy( + role_name: r.role_name, + policy_arn: policy_arn + ) + rescue ::Aws::IAM::Errors::NoSuchEntity + end } versions = MU::Cloud::AWS.iam(credentials: credentials).list_policy_versions( policy_arn: policy_arn, ).versions versions.each { |v| next if v.is_default_version - MU::Cloud::AWS.iam(credentials: credentials).delete_policy_version( - policy_arn: policy_arn, - version_id: v.version_id - ) + begin + MU::Cloud::AWS.iam(credentials: credentials).delete_policy_version( + policy_arn: policy_arn, + version_id: v.version_id + ) + rescue ::Aws::IAM::Errors::NoSuchEntity + end } # Delete the policy, unless it's one of the global canned ones owned # by AWS if !policy_arn.match(/^arn:aws:iam::aws:/) - MU::Cloud::AWS.iam(credentials: credentials).delete_policy( - policy_arn: policy_arn - ) + begin + MU::Cloud::AWS.iam(credentials: credentials).delete_policy( + policy_arn: policy_arn + ) + rescue ::Aws::IAM::Errors::NoSuchEntity + end end end @@ -334,11 +355,25 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent } end + deleteme = [] resp = MU::Cloud::AWS.iam(credentials: credentials).list_roles( path_prefix: "/"+MU.deploy_id+"/" ) - if resp and resp.roles - resp.roles.each { |r| + deleteme.concat(resp.roles) if resp and resp.roles + if flags and flags["known"] + resp = MU::Cloud::AWS.iam(credentials: credentials).list_roles( + max_items: 1000 + ) + if resp and resp.roles + resp.roles.each { |r| + deleteme << r if flags["known"].include?(r.role_name) + } + end + deleteme.uniq! + end + + if deleteme.size > 0 + deleteme.each { |r| MU.log "Deleting IAM role #{r.role_name}" if !noop # purgePolicy won't touch roles we don't own, so gently detach @@ -374,11 +409,8 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent end # Locate an existing user group. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching user group. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching user group. + def self.find(**args) found = nil found @@ -438,7 +470,7 @@ def bindTo(entitytype, entityname) path_prefix: "/"+@deploy.deploy_id+"/", user_name: entityname ) - if !resp or !resp.attached_policies.map { |p| p.policy_name }.include?(p.policy_name) + if !resp or !resp.attached_policies.map { |a_p| a_p.policy_name }.include?(p.policy_name) MU.log "Attaching IAM policy #{p.policy_name} to user #{entityname}", MU::NOTICE MU::Cloud::AWS.iam(credentials: @config['credentials']).attach_user_policy( policy_arn: p.arn, @@ -450,7 +482,7 @@ def bindTo(entitytype, entityname) path_prefix: "/"+@deploy.deploy_id+"/", group_name: entityname ) - if !resp or !resp.attached_policies.map { |p| p.policy_name }.include?(p.policy_name) + if !resp or !resp.attached_policies.map { |a_p| a_p.policy_name }.include?(p.policy_name) MU.log "Attaching policy #{p.policy_name} to group #{entityname}", MU::NOTICE MU::Cloud::AWS.iam(credentials: @config['credentials']).attach_group_policy( policy_arn: p.arn, @@ -462,7 +494,7 @@ def bindTo(entitytype, entityname) role_name: entityname ) - if !resp or !resp.attached_policies.map { |p| p.policy_name }.include?(p.policy_name) + if !resp or !resp.attached_policies.map { |a_p| a_p.policy_name }.include?(p.policy_name) MU.log "Attaching policy #{p.policy_name} to role #{entityname}", MU::NOTICE MU::Cloud::AWS.iam(credentials: @config['credentials']).attach_role_policy( policy_arn: p.arn, @@ -563,6 +595,11 @@ def self.schema(config) "tags" => MU::Config.tags_primitive, "optional_tags" => MU::Config.optional_tags_primitive, "policies" => self.condition_schema, + "strip_path" => { + "type" => "boolean", + "default" => false, + "description" => "Normally we namespace IAM roles with a +path+ set to match our +deploy_id+; this disables that behavior. Temporary workaround for a bug in EKS/IAM integration." + }, "import" => { "items" => { "description" => "Can be a shorthand reference to a canned IAM policy like +AdministratorAccess+, or a full ARN like +arn:aws:iam::aws:policy/AmazonESCognitoAccess+" @@ -734,8 +771,13 @@ def self.genPolicyDocument(policies, deploy_obj: nil) ) if sibling id = sibling.cloudobj.arn - id += target["path"] if target["path"] + id.sub!(/:([^:]+)$/, ":"+target["path"]) if target["path"] doc["Statement"].first["Resource"] << id + if id.match(/:log-group:/) + stream_id = id.sub(/:([^:]+)$/, ":log-stream:*") +# "arn:aws:logs:us-east-2:accountID:log-group:log_group_name:log-stream:CloudTrail_log_stream_name_prefix*" + doc["Statement"].first["Resource"] << stream_id + end else raise MuError, "Couldn't find a #{target["entity_type"]} named #{target["identifier"]} when generating IAM policy" end diff --git a/modules/mu/clouds/aws/search_domain.rb b/modules/mu/clouds/aws/search_domain.rb index 0e5a8f041..7a05e1ee3 100644 --- a/modules/mu/clouds/aws/search_domain.rb +++ b/modules/mu/clouds/aws/search_domain.rb @@ -17,21 +17,11 @@ class Cloud class AWS # A search_domain as configured in {MU::Config::BasketofKittens::search_domains} class SearchDomain < MU::Cloud::SearchDomain - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - @cloudformation_data = {} - attr_reader :cloudformation_data - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::search_domains} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"]) end @@ -42,7 +32,6 @@ def create params = genParams MU.log "Creating ElasticSearch domain #{@config['domain_name']}", details: params - pp params resp = MU::Cloud::AWS.elasticsearch(region: @config['region'], credentials: @config['credentials']).create_elasticsearch_domain(params).domain_status tagDomain @@ -96,6 +85,11 @@ def notify deploy_struct['tags'] = tags.map { |t| { t.key => t.value } } if deploy_struct['endpoint'] deploy_struct['kibana'] = deploy_struct['endpoint']+"/_plugin/kibana/" + elsif deploy_struct['endpoints'] + deploy_struct['kibana'] = {} + deploy_struct['endpoints'].each_pair { |k, v| + deploy_struct['kibana'][k] = v+"/_plugin/kibana/" + } end deploy_struct['domain_name'] ||= @config['domain_name'] if @config['domain_name'] deploy_struct @@ -122,20 +116,25 @@ def self.quality def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) list = MU::Cloud::AWS.elasticsearch(region: region).list_domain_names if list and list.domain_names and list.domain_names.size > 0 - descs = MU::Cloud::AWS.elasticsearch(region: region).describe_elasticsearch_domains(domain_names: list.domain_names.map { |d| d.domain_name } ) - - descs.domain_status_list.each { |domain| - tags = MU::Cloud::AWS.elasticsearch(region: region).list_tags(arn: domain.arn) - tags.tag_list.each { |tag| - if tag.key == "MU-ID" and tag.value == MU.deploy_id - MU.log "Deleting ElasticSearch Domain #{domain.domain_name}" - if !noop - MU::Cloud::AWS.elasticsearch(region: region).delete_elasticsearch_domain(domain_name: domain.domain_name) + names = list.domain_names.map { |d| d.domain_name } + begin + # why is this API so obnoxious? + sample = names.slice!(0, (names.length >= 5 ? 5 : names.length)) + descs = MU::Cloud::AWS.elasticsearch(region: region).describe_elasticsearch_domains(domain_names: sample) + + descs.domain_status_list.each { |domain| + tags = MU::Cloud::AWS.elasticsearch(region: region).list_tags(arn: domain.arn) + tags.tag_list.each { |tag| + if tag.key == "MU-ID" and tag.value == MU.deploy_id + MU.log "Deleting ElasticSearch Domain #{domain.domain_name}" + if !noop + MU::Cloud::AWS.elasticsearch(region: region).delete_elasticsearch_domain(domain_name: domain.domain_name) + end + break end - break - end + } } - } + end while names.size > 0 end unless noop @@ -152,18 +151,15 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent end # Locate an existing search_domain. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching search_domain. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) - if cloud_id + # @return [Hash]: The cloud provider's complete descriptions of matching search_domain. + def self.find(**args) + if args[:cloud_id] # Annoyingly, we might expect one of several possible artifacts, # since AWS couldn't decide what the real identifier of these # things should be - list = MU::Cloud::AWS.elasticsearch(region: region, credentials: credentials).list_domain_names + list = MU::Cloud::AWS.elasticsearch(region: args[:region], credentials: args[:credentials]).list_domain_names if list and list.domain_names and list.domain_names.size > 0 - descs = MU::Cloud::AWS.elasticsearch(region: region, credentials: credentials).describe_elasticsearch_domains(domain_names: list.domain_names.map { |d| d.domain_name } ) + descs = MU::Cloud::AWS.elasticsearch(region: args[:region], credentials: args[:credentials]).describe_elasticsearch_domains(domain_names: list.domain_names.map { |d| d.domain_name } ) descs.domain_status_list.each { |domain| return domain if domain.arn == cloud_id return domain if domain.domain_name == cloud_id @@ -180,19 +176,26 @@ def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource def self.schema(config) toplevel_required = ["elasticsearch_version", "instance_type"] - versions = MU::Cloud::AWS.elasticsearch.list_elasticsearch_versions.elasticsearch_versions - instance_types = nil - begin - instance_types = MU::Cloud::AWS.elasticsearch.list_elasticsearch_instance_types( + + versions = begin + MU::Cloud::AWS.elasticsearch.list_elasticsearch_versions.elasticsearch_versions + rescue MuError => e + ["7.1", "6.8", "6.7", "6.5", "6.4", "6.3", "6.2", "6.0", "5.6"] + end + instance_types = begin + MU::Cloud::AWS.elasticsearch.list_elasticsearch_instance_types( elasticsearch_version: "6.3" ).elasticsearch_instance_types + rescue MuError + ["c5.large.elasticsearch", "c5.xlarge.elasticsearch", "c5.2xlarge.elasticsearch", "c5.4xlarge.elasticsearch", "c5.9xlarge.elasticsearch", "c5.18xlarge.elasticsearch", "i3.large.elasticsearch", "i3.xlarge.elasticsearch", "i3.2xlarge.elasticsearch", "i3.4xlarge.elasticsearch", "i3.8xlarge.elasticsearch", "i3.16xlarge.elasticsearch", "m5.large.elasticsearch", "m5.xlarge.elasticsearch", "m5.2xlarge.elasticsearch", "m5.4xlarge.elasticsearch", "m5.12xlarge.elasticsearch", "r5.large.elasticsearch", "r5.xlarge.elasticsearch", "r5.2xlarge.elasticsearch", "r5.4xlarge.elasticsearch", "r5.12xlarge.elasticsearch", "t2.small.elasticsearch", "t2.medium.elasticsearch", "c4.large.elasticsearch", "c4.xlarge.elasticsearch", "c4.2xlarge.elasticsearch", "c4.4xlarge.elasticsearch", "c4.8xlarge.elasticsearch", "i2.xlarge.elasticsearch", "i2.2xlarge.elasticsearch", "m4.large.elasticsearch", "m4.xlarge.elasticsearch", "m4.2xlarge.elasticsearch", "m4.4xlarge.elasticsearch", "m4.10xlarge.elasticsearch", "r4.large.elasticsearch", "r4.xlarge.elasticsearch", "r4.2xlarge.elasticsearch", "r4.4xlarge.elasticsearch", "r4.8xlarge.elasticsearch", "r4.16xlarge.elasticsearch", "m3.medium.elasticsearch", "m3.large.elasticsearch", "m3.xlarge.elasticsearch", "m3.2xlarge.elasticsearch", "r3.large.elasticsearch", "r3.xlarge.elasticsearch", "r3.2xlarge.elasticsearch", "r3.4xlarge.elasticsearch", "r3.8xlarge.elasticsearch"] rescue Aws::ElasticsearchService::Errors::ValidationException # Some regions (GovCloud) lag - instance_types = MU::Cloud::AWS.elasticsearch.list_elasticsearch_instance_types( + MU::Cloud::AWS.elasticsearch.list_elasticsearch_instance_types( elasticsearch_version: "6.2" ).elasticsearch_instance_types end + schema = { "name" => { "type" => "string", @@ -308,6 +311,10 @@ def self.validateConfig(dom, configurator) if dom["dedicated_masters"] > 0 and dom["master_instance_type"].nil? dom["master_instance_type"] = dom["instance_type"] + if dom["dedicated_masters"] != 3 and dom["dedicated_masters"] != 5 + MU.log "SearchDomain #{dom['name']}: You must choose either three or five dedicated master nodes", MU::ERR + ok = false + end end if dom["instance_count"] < 1 @@ -315,6 +322,11 @@ def self.validateConfig(dom, configurator) ok = false end + if dom["ebs_iops"] + MU.log "SearchDomain #{dom['name']} declared ebs_iops, setting volume type to io1", MU::NOTICE + dom["ebs_type"] = "io1" + end + if dom["zone_aware"] and (dom["instance_count"] % 2) != 0 MU.log "Must set an even number for instance_count when enabling Zone Awareness in SearchDomain '#{dom['name']}'", MU::ERR ok = false @@ -557,6 +569,10 @@ def genParams(ext = nil) } end + # XXX this will break on regroom, revisit and make deterministic + # or remembered + subnet_ids = subnet_ids.sample(3) if subnet_ids.size > 3 + if ext.nil? or ext.vpc_options.subnet_ids != subnet_ids or ext.vpc_options.security_group_ids != sgs @@ -564,6 +580,11 @@ def genParams(ext = nil) params[:vpc_options][:subnet_ids] = subnet_ids params[:vpc_options][:security_group_ids] = sgs end + if @config['zone_aware'] and params[:elasticsearch_cluster_config] + params[:elasticsearch_cluster_config][:zone_awareness_config] = { + :availability_zone_count => subnet_ids.size + } + end end if @config['ebs_type'] @@ -653,13 +674,16 @@ def waitWhileProcessing begin resp = cloud_desc - if (resp.endpoint.nil? or resp.endpoint.empty?) and !resp.deleted + + if (resp.endpoint.nil? or resp.endpoint.empty?) and + (resp.endpoints.nil? or resp.endpoints.empty?) and + !resp.deleted loglevel = (retries > 0 and retries % 3 == 0) ? MU::NOTICE : MU::DEBUG MU.log "Waiting for Elasticsearch domain #{@mu_name} (#{@config['domain_name']}) to finish creating", loglevel sleep interval end retries += 1 - end while (resp.endpoint.nil? or resp.endpoint.empty?) and !resp.deleted + end while (resp.endpoint.nil? or resp.endpoint.empty?) and (resp.endpoints.nil? or resp.endpoints.empty?) and !resp.deleted end end diff --git a/modules/mu/clouds/aws/server.rb b/modules/mu/clouds/aws/server.rb index bd8debafa..3b34d2f2e 100644 --- a/modules/mu/clouds/aws/server.rb +++ b/modules/mu/clouds/aws/server.rb @@ -75,31 +75,26 @@ def self.ephemeral_mappings @ephemeral_mappings end - attr_reader :mu_name - attr_reader :config - attr_reader :deploy - attr_reader :cloud_id - attr_reader :cloud_desc - attr_reader :groomer - attr_accessor :mu_windows_name - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::servers} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id = cloud_id - - if @deploy - @userdata = MU::Cloud.fetchUserdata( + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + @userdata = if @config['userdata_script'] + @config['userdata_script'] + elsif @deploy and !@config['scrub_mu_isms'] + MU::Cloud.fetchUserdata( platform: @config["platform"], - cloud: "aws", + cloud: "AWS", + credentials: @config['credentials'], template_variables: { "deployKey" => Base64.urlsafe_encode64(@deploy.public_key), "deploySSHKey" => @deploy.ssh_public_key, "muID" => MU.deploy_id, "muUser" => MU.mu_user, "publicIP" => MU.mu_public_ip, + "mommaCatPort" => MU.mommaCatPort, + "adminBucketName" => MU::Cloud::AWS.adminBucketName(@credentials), + "chefVersion" => MU.chefVersion, "skipApplyUpdates" => @config['skipinitialupdates'], "windowsAdminName" => @config['windows_admin_username'], "resourceName" => @config["name"], @@ -113,10 +108,8 @@ def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) @disk_devices = MU::Cloud::AWS::Server.disk_devices @ephemeral_mappings = MU::Cloud::AWS::Server.ephemeral_mappings - if !mu_name.nil? - @mu_name = mu_name + if !@mu_name.nil? @config['mu_name'] = @mu_name - # describe @mu_windows_name = @deploydata['mu_windows_name'] if @mu_windows_name.nil? and @deploydata else if kitten_cfg.has_key?("basis") @@ -126,9 +119,8 @@ def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) end @config['mu_name'] = @mu_name - @config['instance_secret'] = Password.random(50) end - @groomer = MU::Groomer.new(self) + @config['instance_secret'] ||= Password.random(50) end @@ -246,7 +238,7 @@ def create end MU::MommaCat.unlock(instance.instance_id+"-create") else - MU::MommaCat.createStandardTags(instance.instance_id, region: @config['region'], credentials: @config['credentials']) + MU::Cloud::AWS.createStandardTags(instance.instance_id, region: @config['region'], credentials: @config['credentials']) MU::MommaCat.createTag(instance.instance_id, "Name", @mu_name, region: @config['region'], credentials: @config['credentials']) end done = true @@ -288,8 +280,9 @@ def createEc2Instance if @config['generate_iam_role'] role = @deploy.findLitterMate(name: @config['name'], type: "roles") s3_objs = ["#{@deploy.deploy_id}-secret", "#{role.mu_name}.pfx", "#{role.mu_name}.crt", "#{role.mu_name}.key", "#{role.mu_name}-winrm.crt", "#{role.mu_name}-winrm.key"].map { |file| - 'arn:'+(MU::Cloud::AWS.isGovCloud?(@config['region']) ? "aws-us-gov" : "aws")+':s3:::'+MU.adminBucketName+'/'+file + 'arn:'+(MU::Cloud::AWS.isGovCloud?(@config['region']) ? "aws-us-gov" : "aws")+':s3:::'+MU::Cloud::AWS.adminBucketName(@credentials)+'/'+file } + MU.log "Adding S3 read permissions to #{@mu_name}'s IAM profile", MU::NOTICE, details: s3_objs role.cloudobj.injectPolicyTargets("MuSecrets", s3_objs) @config['iam_role'] = role.mu_name @@ -382,6 +375,15 @@ def createEc2Instance instance_descriptor[:block_device_mappings].concat(@ephemeral_mappings) instance_descriptor[:monitoring] = {enabled: @config['monitoring']} + if @tags and @tags.size > 0 + instance_descriptor[:tag_specifications] = [{ + :resource_type => "instance", + :tags => @tags.keys.map { |k| + { :key => k, :value => @tags[k] } + } + }] + end + MU.log "Creating EC2 instance #{node}" MU.log "Instance details for #{node}: #{instance_descriptor}", MU::DEBUG # if instance_descriptor[:block_device_mappings].empty? @@ -389,8 +391,16 @@ def createEc2Instance # end retries = 0 - begin + instance = begin response = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).run_instances(instance_descriptor) + if response and response.instances and response.instances.size > 0 + instance = response.instances.first + else + MU.log "halp", MU::ERR, details: response + end + rescue Aws::EC2::Errors::InvalidRequest => e + MU.log e.message, MU::ERR, details: instance_descriptor + raise e rescue Aws::EC2::Errors::InvalidGroupNotFound, Aws::EC2::Errors::InvalidSubnetIDNotFound, Aws::EC2::Errors::InvalidParameterValue => e if retries < 10 if retries > 7 @@ -404,11 +414,9 @@ def createEc2Instance end end - instance = response.instances.first MU.log "#{node} (#{instance.instance_id}) coming online" - return instance - + instance end # Ask the Amazon API to restart this node @@ -424,7 +432,10 @@ def reboot(hard = false) groupname = resp.auto_scaling_instances.first.auto_scaling_group_name MU.log "Pausing Autoscale processes in #{groupname}", MU::NOTICE MU::Cloud::AWS.autoscale(region: @config['region'], credentials: @config['credentials']).suspend_processes( - auto_scaling_group_name: groupname + auto_scaling_group_name: groupname, + scaling_processes: [ + "Terminate", + ], ) end begin @@ -445,7 +456,10 @@ def reboot(hard = false) if !groupname.nil? MU.log "Resuming Autoscale processes in #{groupname}", MU::NOTICE MU::Cloud::AWS.autoscale(region: @config['region'], credentials: @config['credentials']).resume_processes( - auto_scaling_group_name: groupname + auto_scaling_group_name: groupname, + scaling_processes: [ + "Terminate", + ], ) end end @@ -515,7 +529,7 @@ def postBoot(instance_id = nil) return false if !MU::MommaCat.lock(instance.instance_id+"-orchestrate", true) return false if !MU::MommaCat.lock(instance.instance_id+"-groom", true) - MU::MommaCat.createStandardTags(instance.instance_id, region: @config['region'], credentials: @config['credentials']) + MU::Cloud::AWS.createStandardTags(instance.instance_id, region: @config['region'], credentials: @config['credentials']) MU::MommaCat.createTag(instance.instance_id, "Name", node, region: @config['region'], credentials: @config['credentials']) if @config['optional_tags'] @@ -748,11 +762,11 @@ def postBoot(instance_id = nil) # extra interfaces to accomodate. if !@config['vpc']['subnets'].nil? and @config['basis'].nil? device_index = 1 - @vpc.subnets { |subnet| - subnet_id = subnet.cloud_id + @vpc.subnets { |s| + subnet_id = s.cloud_id MU.log "Adding network interface on subnet #{subnet_id} for #{node}" iface = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).create_network_interface(subnet_id: subnet_id).network_interface - MU::MommaCat.createStandardTags(iface.network_interface_id, region: @config['region'], credentials: @config['credentials']) + MU::Cloud::AWS.createStandardTags(iface.network_interface_id, region: @config['region'], credentials: @config['credentials']) MU::MommaCat.createTag(iface.network_interface_id, "Name", node+"-ETH"+device_index.to_s, region: @config['region'], credentials: @config['credentials']) if @config['optional_tags'] @@ -956,15 +970,16 @@ def postBoot(instance_id = nil) # postBoot # Locate an existing instance or instances and return an array containing matching AWS resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @param flags [Hash]: Optional flags - # @return [Array>]: The cloud provider's complete descriptions of matching instances - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, credentials: nil, flags: {}) -# XXX put that 'ip' value into opts - ip ||= flags['ip'] + # @return [Hash]: The cloud provider's complete descriptions of matching instances + def self.find(**args) + ip ||= args[:flags]['ip'] if args[:flags] and args[:flags]['ip'] + + cloud_id = args[:cloud_id] + region = args[:region] + credentials = args[:credentials] + tag_key = args[:tag_key] + tag_value = args[:tag_value] + instance = nil if !region.nil? regions = [region] @@ -978,21 +993,21 @@ def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: n # If we got an instance id, go get it if !cloud_id.nil? and !cloud_id.empty? - regions.each { |region| + regions.each { |r| search_threads << Thread.new { - MU.log "Hunting for instance with cloud id '#{cloud_id}' in #{region}", MU::DEBUG + MU.log "Hunting for instance with cloud id '#{cloud_id}' in #{r}", MU::DEBUG retries = 0 begin - MU::Cloud::AWS.ec2(region: region, credentials: credentials).describe_instances( + MU::Cloud::AWS.ec2(region: r, credentials: credentials).describe_instances( instance_ids: [cloud_id], filters: [ {name: "instance-state-name", values: ["running", "pending"]} ] ).reservations.each { |resp| if !resp.nil? and !resp.instances.nil? - resp.instances.each { |instance| + resp.instances.each { |i| search_semaphore.synchronize { - found_instances[instance.instance_id] = instance + found_instances[i.instance_id] = i } } end @@ -1002,7 +1017,7 @@ def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: n retries = retries + 1 sleep 5 else - raise MuError, "#{e.inspect} in region #{region}" + raise MuError, "#{e.inspect} in region #{r}" end end } @@ -1048,8 +1063,8 @@ def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: n ] ).reservations.each { |resp| if !resp.nil? and resp.instances.size > 0 - resp.instances.each { |instance| - found_instances[instance.instance_id] = instance + resp.instances.each { |i| + found_instances[i.instance_id] = i } end } @@ -1218,12 +1233,12 @@ def groom purgecmd = "rm -rf /cygdrive/c/chef/ /home/#{@config['windows_admin_username']}/.ssh/authorized_keys /home/Administrator/.ssh/authorized_keys /cygdrive/c/mu-installer-ran-updates /cygdrive/c/mu_installed_chef" # session.exec!("powershell -Command \"& {(Get-WmiObject -Class Win32_Product -Filter \"Name='UniversalForwarder'\").Uninstall()}\"") else - purgecmd = "#{sudo} rm -rf /root/.ssh/authorized_keys /etc/ssh/ssh_host_*key* /etc/chef /etc/opscode/* /.mu-installer-ran-updates /var/chef /opt/mu_installed_chef /opt/chef ; #{sudo} sed -i 's/^HOSTNAME=.*//' /etc/sysconfig/network" + purgecmd = "#{sudo} rm -rf /var/lib/cloud/instances/i-* /root/.ssh/authorized_keys /etc/ssh/ssh_host_*key* /etc/chef /etc/opscode/* /.mu-installer-ran-updates /var/chef /opt/mu_installed_chef /opt/chef ; #{sudo} sed -i 's/^HOSTNAME=.*//' /etc/sysconfig/network" end end session.exec!(purgecmd) session.close - ami_id = MU::Cloud::AWS::Server.createImage( + ami_ids = MU::Cloud::AWS::Server.createImage( name: @mu_name, instance_id: @cloud_id, storage: @config['storage'], @@ -1234,11 +1249,11 @@ def groom tags: @config['tags'], credentials: @config['credentials'] ) - @deploy.notify("images", @config['name'], {"image_id" => ami_id}) + @deploy.notify("images", @config['name'], ami_ids) @config['image_created'] = true if img_cfg['image_then_destroy'] - MU::Cloud::AWS::Server.waitForAMI(ami_id, region: @config['region'], credentials: @config['credentials']) - MU.log "AMI #{ami_id} ready, removing source node #{node}" + MU::Cloud::AWS::Server.waitForAMI(ami_ids[@config['region']], region: @config['region'], credentials: @config['credentials']) + MU.log "AMI #{ami_ids[@config['region']]} ready, removing source node #{node}" MU::Cloud::AWS::Server.terminateInstance(id: @cloud_id, region: @config['region'], deploy_id: @deploy.deploy_id, mu_name: @mu_name, credentials: @config['credentials']) destroy end @@ -1253,12 +1268,19 @@ def arn "arn:"+(MU::Cloud::AWS.isGovCloud?(@config["region"]) ? "aws-us-gov" : "aws")+":ec2:"+@config['region']+":"+MU::Cloud::AWS.credToAcct(@config['credentials'])+":instance/"+@cloud_id end + # Return the cloud provider's description for this instance + # @return [Openstruct] def cloud_desc max_retries = 5 retries = 0 if !@cloud_id.nil? begin - return MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).describe_instances(instance_ids: [@cloud_id]).reservations.first.instances.first + resp = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).describe_instances(instance_ids: [@cloud_id]) + if resp and resp.reservations and resp.reservations.first and + resp.reservations.first.instances and + resp.reservations.first.instances.first + return resp.reservations.first.instances.first + end rescue Aws::EC2::Errors::InvalidInstanceIDNotFound return nil rescue NoMethodError => e @@ -1325,10 +1347,11 @@ def canonicalIP # @return [String]: The cloud provider identifier of the new machine image. def self.createImage(name: nil, instance_id: nil, storage: {}, exclude_storage: false, make_public: false, region: MU.curRegion, copy_to_regions: [], tags: [], credentials: nil) ami_descriptor = { - :instance_id => instance_id, - :name => name, - :description => "Image automatically generated by Mu from #{name}" + :instance_id => instance_id, + :name => name, + :description => "Image automatically generated by Mu from #{name}" } + ami_ids = {} storage_list = Array.new if exclude_storage @@ -1361,8 +1384,11 @@ def self.createImage(name: nil, instance_id: nil, storage: {}, exclude_storage: MU.log "AMI #{name} already exists, skipping", MU::WARN return nil end + ami = resp.image_id - MU::MommaCat.createStandardTags(ami, region: region, credentials: credentials) + + ami_ids[region] = ami + MU::Cloud::AWS.createStandardTags(ami, region: region, credentials: credentials) MU::MommaCat.createTag(ami, "Name", name, region: region, credentials: credentials) MU.log "AMI of #{name} in region #{region}: #{ami}" if make_public @@ -1388,8 +1414,9 @@ def self.createImage(name: nil, instance_id: nil, storage: {}, exclude_storage: description: "Image automatically generated by Mu from #{name}" ) MU.log "Initiated copy of #{ami} from #{region} to #{r}: #{copy.image_id}" + ami_ids[r] = copy.image_id - MU::MommaCat.createStandardTags(copy.image_id, region: r, credentials: credentials) + MU::Cloud::AWS.createStandardTags(copy.image_id, region: r, credentials: credentials) MU::MommaCat.createTag(copy.image_id, "Name", name, region: r, credentials: credentials) if !tags.nil? tags.each { |tag| @@ -1413,7 +1440,7 @@ def self.createImage(name: nil, instance_id: nil, storage: {}, exclude_storage: t.join } - return resp.image_id + return ami_ids end # Given a cloud platform identifier for a machine image, wait until it's @@ -1608,7 +1635,8 @@ def self.findFreeElasticIp(classic: false, ip: nil, region: MU.curRegion) # @param dev [String]: Device name to use when attaching to instance # @param size [String]: Size (in gb) of the new volume # @param type [String]: Cloud storage type of the volume, if applicable - def addVolume(dev, size, type: "gp2") + # @param delete_on_termination [Boolean]: Value of delete_on_termination flag to set + def addVolume(dev, size, type: "gp2", delete_on_termination: false) if @cloud_id.nil? or @cloud_id.empty? MU.log "#{self} didn't have a cloud id, couldn't determine 'active?' status", MU::ERR return true @@ -1619,10 +1647,26 @@ def addVolume(dev, size, type: "gp2") ).reservations.each { |resp| if !resp.nil? and !resp.instances.nil? resp.instances.each { |instance| - az = instance.placement.availability_zone - instance.block_device_mappings.each { |vol| - if vol.device_name == dev + az = instance.placement.availability_zone + d_o_t_changed = true + mappings = MU.structToHash(instance.block_device_mappings) + mappings.each { |vol| + if vol[:ebs] + vol[:ebs].delete(:attach_time) + vol[:ebs].delete(:status) + end + } + mappings.each { |vol| + if vol[:device_name] == dev MU.log "A volume #{dev} already attached to #{self}, skipping", MU::NOTICE + if vol[:ebs][:delete_on_termination] != delete_on_termination + vol[:ebs][:delete_on_termination] = delete_on_termination + MU.log "Setting delete_on_termination flag to #{delete_on_termination.to_s} on #{@mu_name}'s #{dev}" + MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).modify_instance_attribute( + instance_id: @cloud_id, + block_device_mappings: mappings + ) + end return end } @@ -1663,6 +1707,32 @@ def addVolume(dev, size, type: "gp2") raise MuError, "Saw state '#{creation.state}' while creating #{size}GB #{type} volume on #{dev} for #{@cloud_id}" end end while attachment.state != "attached" + + # Set delete_on_termination, which for some reason is an instance + # attribute and not on the attachment + mappings = MU.structToHash(cloud_desc.block_device_mappings) + changed = false + + mappings.each { |mapping| + if mapping[:ebs] + mapping[:ebs].delete(:attach_time) + mapping[:ebs].delete(:status) + end + if mapping[:device_name] == dev and + mapping[:ebs][:delete_on_termination] != delete_on_termination + changed = true + mapping[:ebs][:delete_on_termination] = delete_on_termination + end + } + + if changed + MU.log "Setting delete_on_termination flag to #{delete_on_termination.to_s} on #{@mu_name}'s #{dev}" + MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).modify_instance_attribute( + instance_id: @cloud_id, + block_device_mappings: mappings + ) + end + end # Determine whether the node in question exists at the Cloud provider @@ -1934,7 +2004,7 @@ def self.terminateInstance(instance: nil, noop: false, id: nil, onlycloud: false end if !onlycloud and !mu_name.nil? # DNS cleanup is now done in MU::Cloud::DNSZone. Keeping this for now - if !zone_rrsets.empty? + if !zone_rrsets.nil? and !zone_rrsets.empty? zone_rrsets.each { |rrset| if rrset.name.match(/^#{mu_name.downcase}\.server\.#{MU.myInstanceId}\.platform-mu/i) rrset.resource_records.each { |record| @@ -1945,16 +2015,6 @@ def self.terminateInstance(instance: nil, noop: false, id: nil, onlycloud: false } end - # Expunge traces left in Chef, Puppet or what have you - MU::Groomer.supportedGroomers.each { |groomer| - groomclass = MU::Groomer.loadGroomer(groomer) - if !server_obj.nil? and !server_obj.config.nil? and !server_obj.config['vault_access'].nil? - groomclass.cleanup(mu_name, server_obj.config['vault_access'], noop) - else - groomclass.cleanup(mu_name, [], noop) - end - } - if !noop if !server_obj.nil? and !server_obj.config.nil? MU.mommacat.notify(MU::Cloud::Server.cfg_plural, server_obj.config['name'], {}, mu_name: server_obj.mu_name, remove: true) if MU.mommacat @@ -1985,7 +2045,7 @@ def self.terminateInstance(instance: nil, noop: false, id: nil, onlycloud: false known_hosts_files << Etc.getpwnam("nagios").dir+"/.ssh/known_hosts" end known_hosts_files.each { |known_hosts| - next if !File.exists?(known_hosts) + next if !File.exist?(known_hosts) MU.log "Cleaning up #{ips} from #{known_hosts}" if !noop File.open(known_hosts, File::CREAT|File::RDWR, 0644) { |f| @@ -2060,6 +2120,23 @@ def self.terminateInstance(instance: nil, noop: false, id: nil, onlycloud: false end end + # Return a BoK-style config hash describing a NAT instance. We use this + # to approximate NAT gateway functionality with a plain instance. + # @return [Hash] + def self.genericNAT + return { + "cloud" => "AWS", + "bastion" => true, + "size" => "t2.small", + "run_list" => [ "mu-nat" ], + "groomer" => "Ansible", + "platform" => "centos7", + "ssh_user" => "centos", + "associate_public_ip" => true, + "static_ip" => { "assign_ip" => true }, + } + end + # Cloud-specific configuration properties. # @param config [MU::Config]: The calling MU::Config object # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource @@ -2068,11 +2145,7 @@ def self.schema(config) schema = { "ami_id" => { "type" => "string", - "description" => "The Amazon EC2 AMI on which to base this instance. Will use the default appropriate for the platform, if not specified." - }, - "image_id" => { - "type" => "string", - "description" => "Synonymous with ami_id" + "description" => "Alias for +image_id+" }, "generate_iam_role" => { "type" => "boolean", @@ -2127,32 +2200,46 @@ def self.schema(config) # @param region [String]: Region to check against # @return [String,nil] def self.validateInstanceType(size, region) - begin - types = (MU::Cloud::AWS.listInstanceTypes(region))[region] - rescue Aws::Pricing::Errors::UnrecognizedClientException + size = size.dup.to_s + types = begin + (MU::Cloud::AWS.listInstanceTypes(region))[region] + rescue Aws::Pricing::Errors::Unrecognitypes.has_key?(size) MU.log "Saw authentication error communicating with Pricing API, going to assume our instance type is correct", MU::WARN return size end + + return size if types.has_key?(size) + if size.nil? or !types.has_key?(size) # See if it's a type we can approximate from one of the other clouds - gtypes = (MU::Cloud::Google.listInstanceTypes)[MU::Cloud::Google.myRegion] foundmatch = false - if gtypes and gtypes.size > 0 and gtypes.has_key?(size) - vcpu = gtypes[size]["vcpu"] - mem = gtypes[size]["memory"] - ecu = gtypes[size]["ecu"] - types.keys.sort.reverse.each { |type| - features = types[type] - next if ecu == "Variable" and ecu != features["ecu"] - next if features["vcpu"] != vcpu - if (features["memory"] - mem.to_f).abs < 0.10*mem - foundmatch = true - MU.log "You specified a Google Compute instance type '#{size}.' Approximating with Amazon EC2 type '#{type}.'", MU::WARN - size = type - break - end - } - end + + MU::Cloud.availableClouds.each { |cloud| + next if cloud == "AWS" + cloudbase = Object.const_get("MU").const_get("Cloud").const_get(cloud) + foreign_types = (cloudbase.listInstanceTypes).values.first + if foreign_types.size == 1 + foreign_types = foreign_types.values.first + end + if foreign_types and foreign_types.size > 0 and foreign_types.has_key?(size) + vcpu = foreign_types[size]["vcpu"] + mem = foreign_types[size]["memory"] + ecu = foreign_types[size]["ecu"] + types.keys.sort.reverse.each { |type| + features = types[type] + next if ecu == "Variable" and ecu != features["ecu"] + next if features["vcpu"] != vcpu + if (features["memory"] - mem.to_f).abs < 0.10*mem + foundmatch = true + MU.log "You specified #{cloud} instance type '#{size}.' Approximating with Amazon EC2 type '#{type}.'", MU::WARN + size = type + break + end + } + end + break if foundmatch + } + if !foundmatch MU.log "Invalid size '#{size}' for AWS EC2 instance in #{region}. Supported types:", MU::ERR, details: types.keys.sort.join(", ") return nil @@ -2205,8 +2292,8 @@ def self.validateConfig(server, configurator) if server['iam_policies'] role['iam_policies'] = server['iam_policies'].dup end - if server['canned_policies'] - role['import'] = server['canned_policies'].dup + if server['canned_iam_policies'] + role['import'] = server['canned_iam_policies'].dup end if server['iam_role'] # XXX maybe break this down into policies and add those? @@ -2232,9 +2319,9 @@ def self.validateConfig(server, configurator) server['ami_id'] ||= server['image_id'] if server['ami_id'].nil? - if MU::Config.amazon_images.has_key?(server['platform']) and - MU::Config.amazon_images[server['platform']].has_key?(server['region']) - server['ami_id'] = configurator.getTail("server"+server['name']+"AMI", value: MU::Config.amazon_images[server['platform']][server['region']], prettyname: "server"+server['name']+"AMI", cloudtype: "AWS::EC2::Image::Id") + img_id = MU::Cloud.getStockImage("AWS", platform: server['platform'], region: server['region']) + if img_id + server['ami_id'] = configurator.getTail("server"+server['name']+"AMI", value: img_id, prettyname: "server"+server['name']+"AMI", cloudtype: "AWS::EC2::Image::Id") else MU.log "No AMI specified for #{server['name']} and no default available for platform #{server['platform']} in region #{server['region']}", MU::ERR, details: server ok = false @@ -2262,6 +2349,21 @@ def self.validateConfig(server, configurator) ok end + # Return the date/time a machine image was created. + # @param ami_id [String]: AMI identifier of an Amazon Machine Image + # @param credentials [String] + # @return [DateTime] + def self.imageTimeStamp(ami_id, credentials: nil, region: nil) + begin + img = MU::Cloud::AWS.ec2(region: region, credentials: credentials).describe_images(image_ids: [ami_id]).images.first + return DateTime.new if img.nil? + return DateTime.parse(img.creation_date) + rescue Aws::EC2::Errors::InvalidAMIIDNotFound => e + end + + return DateTime.new + end + private # Destroy a volume. diff --git a/modules/mu/clouds/aws/server_pool.rb b/modules/mu/clouds/aws/server_pool.rb index 2085b4c4a..ae9937ec0 100644 --- a/modules/mu/clouds/aws/server_pool.rb +++ b/modules/mu/clouds/aws/server_pool.rb @@ -18,25 +18,11 @@ class AWS # A server pool as configured in {MU::Config::BasketofKittens::server_pools} class ServerPool < MU::Cloud::ServerPool - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :cloud_id - attr_reader :config - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::server_pools} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - if !mu_name.nil? - @mu_name = mu_name - elsif @config['scrub_mu_isms'] - @mu_name = @config['name'] - else - @mu_name = @deploy.getResourceName(@config['name']) - end + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + @mu_name ||= @deploy.getResourceName(@config['name']) end # Called automatically by {MU::Deploy#createResources} @@ -103,11 +89,20 @@ def create desc.instances.each { |member| begin groomthreads << Thread.new { - Thread.abort_on_exception = false MU.dupGlobals(parent_thread_id) MU.log "Initializing #{member.instance_id} in ServerPool #{@mu_name}" MU::MommaCat.lock(member.instance_id+"-mommagroom") - kitten = MU::Cloud::Server.new(mommacat: @deploy, kitten_cfg: @config, cloud_id: member.instance_id) + begin + kitten = MU::Cloud::Server.new(mommacat: @deploy, kitten_cfg: @config, cloud_id: member.instance_id) + rescue RuntimeError => e + if e.message.match(/can't add a new key into hash during iteration/) + MU.log e.message+", retrying", MU::WARN + sleep 3 + retry + else + raise e + end + end MU::MommaCat.lock("#{kitten.cloudclass.name}_#{kitten.config["name"]}-dependencies") MU::MommaCat.unlock("#{kitten.cloudclass.name}_#{kitten.config["name"]}-dependencies") if !kitten.postBoot(member.instance_id) @@ -148,8 +143,6 @@ def create setScaleInProtection(need_instances) end - MU.log "See /var/log/mu-momma-cat.log for asynchronous bootstrap progress.", MU::NOTICE - return asg end @@ -368,18 +361,7 @@ def groom policy_params[:scaling_adjustment] = policy['adjustment'] policy_params[:adjustment_type] = policy['type'] elsif policy["policy_type"] == "TargetTrackingScaling" - def strToSym(hash) - newhash = {} - hash.each_pair { |k, v| - if v.is_a?(Hash) - newhash[k.to_sym] = strToSym(v) - else - newhash[k.to_sym] = v - end - } - newhash - end - policy_params[:target_tracking_configuration] = strToSym(policy['target_tracking_configuration']) + policy_params[:target_tracking_configuration] = MU.strToSym(policy['target_tracking_configuration']) policy_params[:target_tracking_configuration].delete(:preferred_target_group) if policy_params[:target_tracking_configuration][:predefined_metric_specification] and policy_params[:target_tracking_configuration][:predefined_metric_specification][:predefined_metric_type] == "ALBRequestCountPerTarget" @@ -462,18 +444,13 @@ def notify end # Locate an existing ServerPool or ServerPools and return an array containing matching AWS resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @param flags [Hash]: Optional flags - # @return [Array>]: The cloud provider's complete descriptions of matching ServerPools - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching ServerPools + def self.find(**args) found = [] - if cloud_id - resp = MU::Cloud::AWS.autoscale(region: region, credentials: credentials).describe_auto_scaling_groups({ + if args[:cloud_id] + resp = MU::Cloud::AWS.autoscale(region: args[:region], credentials: args[:credentials]).describe_auto_scaling_groups({ auto_scaling_group_names: [ - cloud_id + args[:cloud_id] ], }) return resp.auto_scaling_groups @@ -487,8 +464,15 @@ def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: n # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource def self.schema(config) toplevel_required = [] + + term_policies = MU::Cloud::AWS.credConfig ? MU::Cloud::AWS.autoscale.describe_termination_policy_types.termination_policy_types : ["AllocationStrategy", "ClosestToNextInstanceHour", "Default", "NewestInstance", "OldestInstance", "OldestLaunchConfiguration", "OldestLaunchTemplate"] schema = { + "role_strip_path" => { + "type" => "boolean", + "default" => false, + "description" => "Normally we namespace IAM roles with a +path+ set to match our +deploy_id+; this disables that behavior. Temporary workaround for a bug in EKS/IAM integration." + }, "notifications" => { "type" => "object", "description" => "Send notifications to an SNS topic for basic AutoScaling events", @@ -592,7 +576,7 @@ def self.schema(config) "items" => { "type" => "String", "default" => "Default", - "enum" => MU::Cloud::AWS.autoscale.describe_termination_policy_types.termination_policy_types + "enum" => term_policies } }, "scaling_policies" => { @@ -845,8 +829,12 @@ def self.validateConfig(pool, configurator) ok = false end else + s3_objs = ['arn:'+(MU::Cloud::AWS.isGovCloud?(pool['region']) ? "aws-us-gov" : "aws")+':s3:::'+MU::Cloud::AWS.adminBucketName(pool['credentials'])+'/Mu_CA.pem'] + role = { "name" => pool["name"], + "cloud" => "AWS", + "strip_path" => pool["role_strip_path"], "can_assume" => [ { "entity_id" => "ec2.amazonaws.com", @@ -857,19 +845,15 @@ def self.validateConfig(pool, configurator) { "name" => "MuSecrets", "permissions" => ["s3:GetObject"], - "targets" => [ - { - "identifier" => 'arn:'+(MU::Cloud::AWS.isGovCloud?(pool['region']) ? "aws-us-gov" : "aws")+':s3:::'+MU.adminBucketName+'/Mu_CA.pem' - } - ] + "targets" => s3_objs.map { |f| { "identifier" => f } } } ] } if launch['iam_policies'] role['iam_policies'] = launch['iam_policies'].dup end - if pool['canned_policies'] - role['import'] = pool['canned_policies'].dup + if pool['canned_iam_policies'] + role['import'] = pool['canned_iam_policies'].dup end if pool['iam_role'] # XXX maybe break this down into policies and add those? @@ -885,9 +869,9 @@ def self.validateConfig(pool, configurator) end launch["ami_id"] ||= launch["image_id"] if launch["server"].nil? and launch["instance_id"].nil? and launch["ami_id"].nil? - if MU::Config.amazon_images.has_key?(pool['platform']) and - MU::Config.amazon_images[pool['platform']].has_key?(pool['region']) - launch['ami_id'] = configurator.getTail("pool"+pool['name']+"AMI", value: MU::Config.amazon_images[pool['platform']][pool['region']], prettyname: "pool"+pool['name']+"AMI", cloudtype: "AWS::EC2::Image::Id") + img_id = MU::Cloud.getStockImage("AWS", platform: pool['platform'], region: pool['region']) + if img_id + launch['ami_id'] = configurator.getTail("pool"+pool['name']+"AMI", value: img_id, prettyname: "pool"+pool['name']+"AMI", cloudtype: "AWS::EC2::Image::Id") else ok = false @@ -968,6 +952,7 @@ def self.validateConfig(pool, configurator) if policy["alarms"] && !policy["alarms"].empty? policy["alarms"].each { |alarm| alarm["name"] = "scaling-policy-#{pool["name"]}-#{alarm["name"]}" + alarm["cloud"] = "AWS", alarm['dimensions'] = [] if !alarm['dimensions'] alarm['dimensions'] << { "name" => pool["name"], "cloud_class" => "AutoScalingGroupName" } alarm["namespace"] = "AWS/EC2" if alarm["namespace"].nil? @@ -1093,8 +1078,9 @@ def createUpdateLaunchConfig @config['basis']['launch_config']["ami_id"] = MU::Cloud::AWS::Server.createImage( name: @mu_name, instance_id: @config['basis']['launch_config']["instance_id"], - credentials: @config['credentials'] - ) + credentials: @config['credentials'], + region: @config['region'] + )[@config['region']] end MU::Cloud::AWS::Server.waitForAMI(@config['basis']['launch_config']["ami_id"], credentials: @config['credentials']) @@ -1104,13 +1090,17 @@ def createUpdateLaunchConfig userdata = MU::Cloud.fetchUserdata( platform: @config["platform"], - cloud: "aws", + cloud: "AWS", + credentials: @config['credentials'], template_variables: { "deployKey" => Base64.urlsafe_encode64(@deploy.public_key), "deploySSHKey" => @deploy.ssh_public_key, "muID" => @deploy.deploy_id, "muUser" => MU.chef_user, "publicIP" => MU.mu_public_ip, + "mommaCatPort" => MU.mommaCatPort, + "chefVersion" => MU.chefVersion, + "adminBucketName" => MU::Cloud::AWS.adminBucketName(@credentials), "windowsAdminName" => @config['windows_admin_username'], "skipApplyUpdates" => @config['skipinitialupdates'], "resourceName" => @config["name"], @@ -1154,13 +1144,23 @@ def createUpdateLaunchConfig storage.concat(MU::Cloud::AWS::Server.ephemeral_mappings) + if @config['basis']['launch_config']['generate_iam_role'] + role = @deploy.findLitterMate(name: @config['name'], type: "roles") + if role + s3_objs = ["#{@deploy.deploy_id}-secret", "#{role.mu_name}.pfx", "#{role.mu_name}.crt", "#{role.mu_name}.key", "#{role.mu_name}-winrm.crt", "#{role.mu_name}-winrm.key"].map { |file| + 'arn:'+(MU::Cloud::AWS.isGovCloud?(@config['region']) ? "aws-us-gov" : "aws")+':s3:::'+MU::Cloud::AWS.adminBucketName(@credentials)+'/'+file + } + role.cloudobj.injectPolicyTargets("MuSecrets", s3_objs) + end + end + if !oldlaunch.nil? olduserdata = Base64.decode64(oldlaunch.user_data) - if userdata != olduserdata or - oldlaunch.image_id != @config["basis"]["launch_config"]["ami_id"] or - oldlaunch.ebs_optimized != @config["basis"]["launch_config"]["ebs_optimized"] or - oldlaunch.instance_type != @config["basis"]["launch_config"]["size"] or - oldlaunch.instance_monitoring.enabled != @config["basis"]["launch_config"]["monitoring"] + if userdata == olduserdata and + oldlaunch.image_id == @config["basis"]["launch_config"]["ami_id"] and + oldlaunch.ebs_optimized == @config["basis"]["launch_config"]["ebs_optimized"] and + oldlaunch.instance_type == @config["basis"]["launch_config"]["size"] and + oldlaunch.instance_monitoring.enabled == @config["basis"]["launch_config"]["monitoring"] # XXX check more things # launch.block_device_mappings != storage # XXX block device comparison isn't this simple @@ -1237,11 +1237,6 @@ def createUpdateLaunchConfig if @config['basis']['launch_config']['generate_iam_role'] role = @deploy.findLitterMate(name: @config['name'], type: "roles") -# XXX are these the right patterns for a pool, or did we need wildcards? - s3_objs = ["#{@deploy.deploy_id}-secret", "#{role.mu_name}.pfx", "#{role.mu_name}.crt", "#{role.mu_name}.key", "#{role.mu_name}-winrm.crt", "#{role.mu_name}-winrm.key"].map { |file| - 'arn:'+(MU::Cloud::AWS.isGovCloud?(@config['region']) ? "aws-us-gov" : "aws")+':s3:::'+MU.adminBucketName+'/'+file - } - role.cloudobj.injectPolicyTargets("MuSecrets", s3_objs) @config['iam_role'] = role.mu_name @@ -1370,9 +1365,15 @@ def buildOptionsHash if @config["vpc_zone_identifier"] asg_options[:vpc_zone_identifier] = @config["vpc_zone_identifier"] elsif @config["vpc"] + if !@vpc and @config['vpc'].is_a?(MU::Config::Ref) + @vpc = @config['vpc'].kitten + end subnet_ids = [] + if !@vpc + raise MuError, "Failed to load vpc for Autoscale Group #{@mu_name}" + end if !@config["vpc"]["subnets"].nil? and @config["vpc"]["subnets"].size > 0 @config["vpc"]["subnets"].each { |subnet| subnet_obj = @vpc.getSubnet(cloud_id: subnet["subnet_id"], name: subnet["subnet_name"]) diff --git a/modules/mu/clouds/aws/storage_pool.rb b/modules/mu/clouds/aws/storage_pool.rb index 15d3e45c4..fc8b9a676 100644 --- a/modules/mu/clouds/aws/storage_pool.rb +++ b/modules/mu/clouds/aws/storage_pool.rb @@ -17,18 +17,11 @@ class Cloud class AWS # A storage pool as configured in {MU::Config::BasketofKittens::storage_pools} class StoragePool < MU::Cloud::StoragePool - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :cloud_id - attr_reader :config - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::storage_pools} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config['name']) end @@ -111,40 +104,35 @@ def arn end # Locate an existing storage pool and return an array containing matching AWS resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @param flags [Hash]: Optional flags - # @return [Array>]: The cloud provider's complete descriptions of matching storage pool - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching storage pool + def self.find(**args) map = {} - if cloud_id - storge_pool = MU::Cloud::AWS.efs(region: region, credentials: credentials).describe_file_systems( - file_system_id: cloud_id + if args[:cloud_id] + storge_pool = MU::Cloud::AWS.efs(region: args[:region], credentials: args[:credentials]).describe_file_systems( + file_system_id: args[:cloud_id] ).file_systems.first map[cloud_id] = storge_pool if storge_pool end if tag_value - storage_pools = MU::Cloud::AWS.efs(region: region, credentials: credentials).describe_file_systems.file_systems + storage_pools = MU::Cloud::AWS.efs(region: args[:region], credentials: args[:credentials]).describe_file_systems.file_systems if !storage_pools.empty? storage_pools.each{ |pool| - tags = MU::Cloud::AWS.efs(region: region, credentials: credentials).describe_tags( + tags = MU::Cloud::AWS.efs(region: args[:region], credentials: args[:credentials]).describe_tags( file_system_id: pool.file_system_id ).tags value = nil tags.each{ |tag| - if tag.key == tag_key + if tag.key == args[:tag_key] value = tag.value break end } - if value == tag_value + if value == args[:tag_value] map[pool.file_system_id] = pool break end diff --git a/modules/mu/clouds/aws/user.rb b/modules/mu/clouds/aws/user.rb index 6927726b5..9d9fac2c4 100644 --- a/modules/mu/clouds/aws/user.rb +++ b/modules/mu/clouds/aws/user.rb @@ -17,18 +17,11 @@ class Cloud class AWS # A user as configured in {MU::Config::BasketofKittens::users} class User < MU::Cloud::User - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::users} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= if @config['unique_name'] @deploy.getResourceName(@config["name"]) else @@ -157,30 +150,63 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent resp.policies.each { |policy| MU.log "Deleting policy /#{MU.deploy_id}/#{policy.policy_name}" if !noop - attachments = MU::Cloud::AWS.iam(credentials: credentials).list_entities_for_policy( - policy_arn: policy.arn - ) - attachments.policy_users.each { |u| - MU::Cloud::AWS.iam(credentials: credentials).detach_user_policy( - user_name: u.user_name, - policy_arn: policy.arn - ) - } - attachments.policy_groups.each { |g| - MU::Cloud::AWS.iam(credentials: credentials).detach_role_policy( - group_name: g.group_name, + attachments = begin + MU::Cloud::AWS.iam(credentials: credentials).list_entities_for_policy( policy_arn: policy.arn ) - } - attachments.policy_roles.each { |r| - MU::Cloud::AWS.iam(credentials: credentials).detach_role_policy( - role_name: r.role_name, + rescue ::Aws::IAM::Errors::NoSuchEntity + end + if attachments + begin + attachments.policy_users.each { |u| + MU::Cloud::AWS.iam(credentials: credentials).detach_user_policy( + user_name: u.user_name, + policy_arn: policy.arn + ) + } + rescue ::Aws::IAM::Errors::NoSuchEntity + end + begin + attachments.policy_groups.each { |g| + MU::Cloud::AWS.iam(credentials: credentials).detach_role_policy( + group_name: g.group_name, + policy_arn: policy.arn + ) + } + rescue ::Aws::IAM::Errors::NoSuchEntity + end + begin + attachments.policy_roles.each { |r| + MU::Cloud::AWS.iam(credentials: credentials).detach_role_policy( + role_name: r.role_name, + policy_arn: policy.arn + ) + } + rescue ::Aws::IAM::Errors::NoSuchEntity + end + end + + begin + MU::Cloud::AWS.iam(credentials: credentials).delete_policy( policy_arn: policy.arn ) - } - MU::Cloud::AWS.iam(credentials: credentials).delete_policy( - policy_arn: policy.arn - ) + rescue ::Aws::IAM::Errors::DeleteConflict + versions = MU::Cloud::AWS.iam(credentials: credentials).list_policy_versions( + policy_arn: policy.arn, + ).versions + versions.each { |v| + next if v.is_default_version + begin + MU::Cloud::AWS.iam(credentials: credentials).delete_policy_version( + policy_arn: policy.arn, + version_id: v.version_id + ) + rescue ::Aws::IAM::Errors::NoSuchEntity + end + } + retry + rescue ::Aws::IAM::Errors::NoSuchEntity + end end } end @@ -254,19 +280,16 @@ def arn cloud_desc.arn end - # Locate an existing user group. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching user group. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}) + # Locate an existing IAM user + # @return [Hash]: The cloud provider's complete descriptions of matching user group. + def self.find(**args) found = nil begin - resp = MU::Cloud::AWS.iam.get_user(user_name: cloud_id) + resp = MU::Cloud::AWS.iam.get_user(user_name: args[:cloud_id]) if resp and resp.user found ||= {} - found[cloud_id] = resp.user + found[args[:cloud_id]] = resp.user end rescue ::Aws::IAM::Errors::NoSuchEntity end @@ -282,7 +305,7 @@ def self.schema(config) schema = { "name" => { "type" => "string", - "description" => "A plain IAM user. If the user already exists, we will operate on that existing user. Otherwise, we will attempt to create a new user." + "description" => "A plain IAM user. If the user already exists, we will operate on that existing user. Otherwise, we will attempt to create a new user. AWS IAM does not distinguish between human user accounts and machine accounts." }, "path" => { "type" => "string", diff --git a/modules/mu/clouds/aws/userdata/linux.erb b/modules/mu/clouds/aws/userdata/linux.erb index 7fdf15e1f..7247c4a63 100644 --- a/modules/mu/clouds/aws/userdata/linux.erb +++ b/modules/mu/clouds/aws/userdata/linux.erb @@ -14,7 +14,6 @@ # limitations under the License. updates_run=0 -need_reboot=0 instance_id="`curl http://169.254.169.254/latest/meta-data/instance-id`" region="`curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/[a-z]$//'`" @@ -25,6 +24,12 @@ for d in r s t u ;do fi done +for f in /etc/rc.local /etc/rc.d/rc.local;do + if [ -f $f ];then + chmod 755 $f + fi +done + if ping -c 5 8.8.8.8 > /dev/null; then if [ -f /etc/debian_version ];then if ! grep '^/bin/sh /var/lib/cloud/instance/user-data.txt$' /etc/rc.local > /dev/null;then @@ -35,27 +40,25 @@ if ping -c 5 8.8.8.8 > /dev/null; then if [ ! -f /usr/bin/curl ] ;then /usr/bin/apt-get --fix-missing -y install curl;fi AWSCLI=/usr/local/bin/aws <% if !$mu.skipApplyUpdates %> + set +e if [ ! -f /.mu-installer-ran-updates ];then service ssh stop apt-get --fix-missing -y upgrade + touch /.mu-installer-ran-updates if [ $? -eq 0 ] then echo "Successfully updated packages" updates_run=1 + # XXX this logic works on Ubuntu, is it Debian-friendly? + latest_kernel="`ls -1 /boot/vmlinuz-* | sed -r 's/^\/boot\/vmlinuz-//' | tail -1`" + running_kernel="`uname -r`" + if [ "$running_kernel" != "$latest_kernel" -a "$latest_kernel" != "" ];then + shutdown -r now "Applying new kernel" + fi else echo "FAILED PACKAGE UPDATE" >&2 fi - # Proceed regardless - touch /.mu-installer-ran-updates - - # XXX this logic works on Ubuntu, is it Debian-friendly? - latest_kernel="`ls -1 /boot/vmlinuz-* | sed -r 's/^\/boot\/vmlinuz-//' | tail -1`" - running_kernel="`uname -r`" - if [ "$running_kernel" != "$latest_kernel" -a "$latest_kernel" != "" ];then - need_reboot=1 - else - service ssh start - fi + service ssh start fi <% end %> elif [ -x /usr/bin/yum ];then @@ -75,12 +78,12 @@ if ping -c 5 8.8.8.8 > /dev/null; then cat /etc/rc.d/rc.local | grep -v '^/bin/sh /var/lib/cloud/instances/' >> /tmp/rc.local.$$ echo "/bin/sh $userdata_dir/user-data.txt" >> /tmp/rc.local.$$ mv /tmp/rc.local.$$ /etc/rc.d/rc.local + chmod 755 /etc/rc.d/rc.local fi sed -i 's/^Defaults.*requiretty$/Defaults !requiretty/' /etc/sudoers if [ "$version" == "7" ];then - chmod 755 /etc/rc.d/rc.local systemctl reset-failed sshd.service fi if [ ! -f /usr/bin/curl ] ;then /usr/bin/yum -y install curl;fi @@ -89,27 +92,26 @@ if ping -c 5 8.8.8.8 > /dev/null; then /bin/rpm -ivh http://mirror.metrocast.net/fedora/epel/epel-release-latest-$version.noarch.rpm fi <% if !$mu.skipApplyUpdates %> + set +e if [ ! -f /.mu-installer-ran-updates ];then service sshd stop kernel_update=`yum list updates | grep kernel` yum -y update + touch /.mu-installer-ran-updates if [ $? -eq 0 ] then echo "Successfully updated packages" updates_run=1 + if [ -n "$kernel_update" ]; then + shutdown -r now "Applying new kernel" + fi else echo "FAILED PACKAGE UPDATE" >&2 fi - # Proceed regardless - touch /.mu-installer-ran-updates - if [ -n "$kernel_update" ]; then - need_reboot=1 - else - service sshd start - fi + service sshd start fi - fi <% end %> + fi else /bin/logger "***** Unable to verify internet connectivity, skipping package updates from userdata" touch /.mu-installer-ran-updates @@ -143,27 +145,19 @@ fi umask 0077 -# Install Chef now, because why not? if [ ! -f /opt/chef/embedded/bin/ruby ];then curl https://www.chef.io/chef/install.sh > chef-install.sh set +e # We may run afoul of a synchronous bootstrap process doing the same thing. So # wait until we've managed to run successfully. - while ! sh chef-install.sh -v <%= MU.chefVersion %>;do + while ! sh chef-install.sh -v <%= $mu.chefVersion %>;do sleep 10 done touch /opt/mu_installed_chef - set -e fi -<% if !$mu.skipApplyUpdates %> -if [ "$need_reboot" == "1" ];then - shutdown -r now "Applying new kernel" -fi -<% end %> - if [ "$AWSCLI" != "" ];then - $AWSCLI --region="$region" s3 cp s3://<%= MU.adminBucketName %>/<%= $mu.muID %>-secret . + $AWSCLI --region="$region" s3 cp s3://<%= $mu.adminBucketName %>/<%= $mu.muID %>-secret . fi echo ' @@ -181,6 +175,6 @@ if ! ( netstat -na | grep LISTEN | grep ':22 ' );then service sshd start fi -/usr/bin/curl -k --data mu_id="<%= $mu.muID %>" --data mu_resource_name="<%= $mu.resourceName %>" --data mu_resource_type="<%= $mu.resourceType %>" --data mu_instance_id="$instance_id" --data mu_bootstrap="1" --data mu_user="<%= $mu.muUser %>" --data mu_deploy_secret="`/opt/chef/embedded/bin/ruby encrypt_deploy_secret.rb`" https://<%= $mu.publicIP %>:2260/ +/usr/bin/curl -k --data mu_id="<%= $mu.muID %>" --data mu_resource_name="<%= $mu.resourceName %>" --data mu_resource_type="<%= $mu.resourceType %>" --data mu_instance_id="$instance_id" --data mu_bootstrap="1" --data mu_user="<%= $mu.muUser %>" --data mu_deploy_secret="`/opt/chef/embedded/bin/ruby encrypt_deploy_secret.rb`" https://<%= $mu.publicIP %>:<%= $mu.mommaCatPort %>/ /bin/rm -f <%= $mu.muID %>-secret mu_deploy_key.pub chef-install.sh encrypt_deploy_secret.rb touch /.mu_userdata_complete diff --git a/modules/mu/clouds/aws/userdata/windows.erb b/modules/mu/clouds/aws/userdata/windows.erb index d09ac6dd3..a0f78dd18 100644 --- a/modules/mu/clouds/aws/userdata/windows.erb +++ b/modules/mu/clouds/aws/userdata/windows.erb @@ -23,8 +23,8 @@ function log } function fetchSecret([string]$file){ - log "Fetching s3://<%= MU.adminBucketName %>/$file to $tmp/$file" - aws.cmd --region $region s3 cp s3://<%= MU.adminBucketName %>/$file $tmp/$file + log "Fetching s3://<%= $mu.adminBucketName %>/$file to $tmp/$file" + aws.cmd --region $region s3 cp s3://<%= $mu.adminBucketName %>/$file $tmp/$file } function importCert([string]$cert, [string]$store){ @@ -113,7 +113,7 @@ function removeChef($location){ $install_chef = $false $my_chef = (Get-ItemProperty $location | Where-Object {$_.DisplayName -like "chef client*"}).DisplayName if ($my_chef) { - if ($my_chef -match '<%= MU.chefVersion %>'.split('-')[0]) { + if ($my_chef -match '<%= $mu.chefVersion %>'.split('-')[0]) { $install_chef = $false } else{ log "Uninstalling Chef" @@ -143,13 +143,13 @@ If (!(Test-Path "c:\opscode\chef\embedded\bin\ruby.exe")){ } If ($install_chef){ - log "Installing Chef <%= MU.chefVersion %>" - If (!(Test-Path $env:Temp/chef-installer-<%= MU.chefVersion %>.msi)){ + log "Installing Chef <%= $mu.chefVersion %>" + If (!(Test-Path $env:Temp/chef-installer-<%= $mu.chefVersion %>.msi)){ log "Downloading Chef installer" - $WebClient.DownloadFile("https://www.chef.io/chef/download?p=windows&pv=2012&m=x86_64&v=<%= MU.chefVersion %>","$env:Temp/chef-installer-<%= MU.chefVersion %>.msi") + $WebClient.DownloadFile("https://www.chef.io/chef/download?p=windows&pv=2012&m=x86_64&v=<%= $mu.chefVersion %>","$env:Temp/chef-installer-<%= $mu.chefVersion %>.msi") } log "Running Chef installer" - (Start-Process -FilePath msiexec -ArgumentList "/i $env:Temp\chef-installer-<%= MU.chefVersion %>.msi ALLUSERS=1 /le $env:Temp\chef-client-install.log /qn" -Wait -Passthru).ExitCode + (Start-Process -FilePath msiexec -ArgumentList "/i $env:Temp\chef-installer-<%= $mu.chefVersion %>.msi ALLUSERS=1 /le $env:Temp\chef-client-install.log /qn" -Wait -Passthru).ExitCode Set-Content "c:/mu_installed_chef" "yup" } @@ -163,9 +163,9 @@ $deploy_secret = & "c:\opscode\chef\embedded\bin\ruby" -ropenssl -rbase64 -e "ke function callMomma([string]$act) { $params = @{mu_id='<%= $mu.muID %>';mu_resource_name='<%= $mu.resourceName %>';mu_resource_type='<%= $mu.resourceType %>';mu_instance_id="$awsid";mu_user='<%= $mu.muUser %>';mu_deploy_secret="$deploy_secret";$act="1"} - log "Calling Momma Cat at https://<%= $mu.publicIP %>:2260 with $act" + log "Calling Momma Cat at https://<%= $mu.publicIP %>:<%= $mu.mommaCatPort %> with $act" [System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true} - $resp = Invoke-WebRequest -Uri https://<%= $mu.publicIP %>:2260 -Method POST -Body $params + $resp = Invoke-WebRequest -Uri https://<%= $mu.publicIP %>:<%= $mu.mommaCatPort %> -Method POST -Body $params return $resp.Content } diff --git a/modules/mu/clouds/aws/vpc.rb b/modules/mu/clouds/aws/vpc.rb index 1c177d72f..e8010a5b6 100644 --- a/modules/mu/clouds/aws/vpc.rb +++ b/modules/mu/clouds/aws/vpc.rb @@ -19,28 +19,16 @@ class AWS # Creation of Virtual Private Clouds and associated artifacts (routes, subnets, etc). class VPC < MU::Cloud::VPC - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :cloud_id - attr_reader :config - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::vpcs} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @subnets = [] @subnetcachesemaphore = Mutex.new - @cloud_id = cloud_id - if !mu_name.nil? - @mu_name = mu_name - loadSubnets if !@cloud_id.nil? - elsif @config['scrub_mu_isms'] - @mu_name = @config['name'] - else - @mu_name = @deploy.getResourceName(@config['name']) - end + + loadSubnets if !@cloud_id.nil? + + @mu_name ||= @deploy.getResourceName(@config['name']) end # Called automatically by {MU::Deploy#createResources} @@ -49,7 +37,7 @@ def create resp = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).create_vpc(cidr_block: @config['ip_block']).vpc vpc_id = @config['vpc_id'] = resp.vpc_id - MU::MommaCat.createStandardTags(vpc_id, region: @config['region'], credentials: @config['credentials']) + MU::Cloud::AWS.createStandardTags(vpc_id, region: @config['region'], credentials: @config['credentials']) MU::MommaCat.createTag(vpc_id, "Name", @mu_name, region: @config['region'], credentials: @config['credentials']) if @config['tags'] @@ -87,7 +75,7 @@ def create } end - MU::MommaCat.createStandardTags(rtb.route_table_id, region: @config['region'], credentials: @config['credentials']) + MU::Cloud::AWS.createStandardTags(rtb.route_table_id, region: @config['region'], credentials: @config['credentials']) if @config['optional_tags'] MU::MommaCat.listOptionalTags.each { |key, value| @@ -104,7 +92,7 @@ def create resp = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).create_internet_gateway internet_gateway_id = resp.internet_gateway.internet_gateway_id sleep 5 - MU::MommaCat.createStandardTags(internet_gateway_id, region: @config['region'], credentials: @config['credentials']) + MU::Cloud::AWS.createStandardTags(internet_gateway_id, region: @config['region'], credentials: @config['credentials']) MU::MommaCat.createTag(internet_gateway_id, "Name", @mu_name, region: @config['region'], credentials: @config['credentials']) if @config['tags'] @config['tags'].each { |tag| @@ -202,7 +190,7 @@ def create availability_zone: az ).subnet subnet_id = subnet['subnet_id'] = resp.subnet_id - MU::MommaCat.createStandardTags(subnet_id, region: @config['region'], credentials: @config['credentials']) + MU::Cloud::AWS.createStandardTags(subnet_id, region: @config['region'], credentials: @config['credentials']) MU::MommaCat.createTag(subnet_id, "Name", @mu_name+"-"+subnet['name'], region: @config['region'], credentials: @config['credentials']) if @config['tags'] @config['tags'].each { |tag| @@ -220,7 +208,7 @@ def create begin if resp.state != "available" begin - MU.log "Waiting for Subnet #{subnet_name} (#{subnet_id}) to be available", MU::NOTICE + MU.log "Waiting for Subnet #{subnet_name} (#{subnet_id}) to be available", MU::NOTICE if retries > 0 and (retries % 3) == 0 sleep 5 resp = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).describe_subnets(subnet_ids: [subnet_id]).subnets.first rescue Aws::EC2::Errors::InvalidSubnetIDNotFound => e @@ -308,7 +296,7 @@ def create nat_gateway_id = resp.nat_gateway_id attempts = 0 MU::MommaCat.unlock("nat-gateway-eipalloc") - while resp.state == "pending" + while resp.class.name != "Aws::EC2::Types::NatGateway" or resp.state == "pending" MU.log "Waiting for nat gateway #{nat_gateway_id} () to become available (EIP allocation: #{allocation_id})" if attempts % 5 == 0 sleep 30 begin @@ -386,8 +374,17 @@ def create } MU.log "Creating route for #{route['destination_network']} through NAT gatway #{gateway['id']}", details: route_config + nat_retries = 0 begin resp = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).create_route(route_config) + rescue Aws::EC2::Errors::InvalidNatGatewayIDNotFound => e + if nat_retries < 5 + nat_retries += 1 + sleep 10 + retry + else + raise e + end rescue Aws::EC2::Errors::RouteAlreadyExists => e MU.log "Attempt to create duplicate route to #{route['destination_network']} for #{gateway['id']} in #{rtb['route_table_id']}", MU::WARN end @@ -439,7 +436,7 @@ def create dhcp_configurations: dhcpopts ) dhcpopt_id = resp.dhcp_options.dhcp_options_id - MU::MommaCat.createStandardTags(dhcpopt_id, region: @config['region'], credentials: @config['credentials']) + MU::Cloud::AWS.createStandardTags(dhcpopt_id, region: @config['region'], credentials: @config['credentials']) MU::MommaCat.createTag(dhcpopt_id, "Name", @mu_name, region: @config['region'], credentials: @config['credentials']) if @config['tags'] @@ -472,6 +469,9 @@ def create # Canonical Amazon Resource Number for this resource # @return [String] def arn + puts @config['region'] + puts MU::Cloud::AWS.credToAcct(@config['credentials']) + puts @cloud_id "arn:"+(MU::Cloud::AWS.isGovCloud?(@config["region"]) ? "aws-us-gov" : "aws")+":ec2:"+@config['region']+":"+MU::Cloud::AWS.credToAcct(@config['credentials'])+":vpc/"+@cloud_id end @@ -490,57 +490,77 @@ def groom @config['peers'].each { |peer| peer_obj = nil peer_id = nil - - begin - # If we know this to be a sibling VPC elsewhere in our stack, - # go fetch it, and fix it if we've been misconfigured with a - # duplicate peering connection - if peer['vpc']['vpc_name'] and !peer['account'] - peer_obj = @deploy.findLitterMate(name: peer['vpc']['vpc_name'], type: "vpcs") - if peer_obj - if peer_obj.config['peers'] - skipme = false - peer_obj.config['peers'].each { |peerpeer| - if peerpeer['vpc']['vpc_name'] == @config['name'] and - (peer['vpc']['vpc_name'] <=> @config['name']) == -1 - skipme = true - MU.log "VPCs #{peer['vpc']['vpc_name']} and #{@config['name']} both declare mutual peering connection, ignoring #{@config['name']}'s redundant declaration", MU::DEBUG + peer['name'] ||= peer['vpc_name'] + peer['id'] ||= peer['vpc_id'] + + # If we know this to be a sibling VPC elsewhere in our stack, + # go fetch it, and fix it if we've been misconfigured with a + # duplicate peering connection + if peer['vpc']['name'] and !peer['account'] + peer_obj = @deploy.findLitterMate(name: peer['vpc']['name'], type: "vpcs") + if peer_obj + if peer_obj.config['peers'] + skipme = false + peer_obj.config['peers'].each { |peerpeer| + if peerpeer['vpc']['name'] == @config['name'] and + (peer['vpc']['name'] <=> @config['name']) == -1 + skipme = true + MU.log "VPCs #{peer['vpc']['name']} and #{@config['name']} both declare mutual peering connection, ignoring #{@config['name']}'s redundant declaration", MU::DEBUG # XXX and if deploy_id matches or is unset - end - } - end - next if skipme - peer['account'] = MU::Cloud::AWS.credToAcct(peer_obj.credentials) - peer['vpc']['vpc_id'] = peer_obj.cloud_id + end + } end + next if skipme + peer['account'] = MU::Cloud::AWS.credToAcct(peer_obj.credentials) + peer['vpc']['id'] = peer_obj.cloud_id end + end - # If we still don't know our peer's vpc identifier, go fishing - if !peer_obj - tag_key, tag_value = peer['vpc']['tag'].split(/=/, 2) if !peer['vpc']['tag'].nil? - if peer['vpc']['deploy_id'].nil? and peer['vpc']['vpc_id'].nil? and tag_key.nil? - peer['vpc']['deploy_id'] = @deploy.deploy_id - end - peer_obj = MU::MommaCat.findStray( - "AWS", - "vpcs", - deploy_id: peer['vpc']['deploy_id'], - cloud_id: peer['vpc']['vpc_id'], -# XXX we need a credentials argument here... maybe - name: peer['vpc']['vpc_name'], - tag_key: tag_key, - tag_value: tag_value, - dummy_ok: true, - region: peer['vpc']['region'] - ) - raise MuError, "No result looking for #{@mu_name}'s peer VPCs (#{peer['vpc']})" if peer_obj.nil? or peer_obj.first.nil? - peer_obj = peer_obj.first - peer['account'] ||= MU::Cloud::AWS.credToAcct(peer_obj.credentials) - peer['vpc']['vpc_id'] ||= peer_obj.cloud_id + # If we still don't know our peer's vpc identifier, go fishing + if !peer_obj + tag_key, tag_value = peer['vpc']['tag'].split(/=/, 2) if !peer['vpc']['tag'].nil? + if peer['vpc']['deploy_id'].nil? and peer['vpc']['id'].nil? and tag_key.nil? + peer['vpc']['deploy_id'] = @deploy.deploy_id end + peer_obj = MU::MommaCat.findStray( + "AWS", + "vpcs", + deploy_id: peer['vpc']['deploy_id'], + cloud_id: peer['vpc']['id'], +# XXX we need a credentials argument here... maybe + name: peer['vpc']['name'], + tag_key: tag_key, + tag_value: tag_value, + dummy_ok: true, + region: peer['vpc']['region'] + ) +MU.log "wtf", MU::ERR, details: peer if peer_obj.nil? or peer_obj.first.nil? + raise MuError, "No result looking for #{@mu_name}'s peer VPCs (#{peer['vpc']})" if peer_obj.nil? or peer_obj.first.nil? + peer_obj = peer_obj.first + peer['account'] ||= MU::Cloud::AWS.credToAcct(peer_obj.credentials) + peer['vpc']['id'] ||= peer_obj.cloud_id + end - peer_id = peer['vpc']['vpc_id'] - peer['account'] ||= MU::Cloud::AWS.account_number + peer_id = peer['vpc']['id'] + peer['account'] ||= MU::Cloud::AWS.account_number + + # See if the peering connection exists before we bother + # creating it. + resp = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).describe_vpc_peering_connections( + filters: [ + { + name: "requester-vpc-info.vpc-id", + values: [@cloud_id] + }, + { + name: "accepter-vpc-info.vpc-id", + values: [peer_id.to_s] + } + ] + ) + + peering_id = if !resp or !resp.vpc_peering_connections or + resp.vpc_peering_connections.empty? MU.log "Setting peering connection from VPC #{@config['name']} (#{@cloud_id} in account #{MU::Cloud::AWS.credToAcct(@config['credentials'])}) to #{peer_id} in account #{peer['account']}", MU::INFO, details: peer resp = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).create_vpc_peering_connection( @@ -548,14 +568,14 @@ def groom peer_vpc_id: peer_id, peer_owner_id: peer['account'] ) - - rescue Aws::EC2::Errors::VpcPeeringConnectionAlreadyExists => e - MU.log "Attempt to create duplicate peering connection to #{peer_id} from VPC #{@config['name']}", MU::WARN + resp.vpc_peering_connection.vpc_peering_connection_id + else + resp.vpc_peering_connections.first.vpc_peering_connection_id end - peering_name = @deploy.getResourceName(@config['name']+"-PEER-"+peer['vpc']['vpc_id']) - peering_id = resp.vpc_peering_connection.vpc_peering_connection_id - MU::MommaCat.createStandardTags(peering_id, region: @config['region'], credentials: @config['credentials']) + peering_name = @deploy.getResourceName(@config['name']+"-PEER-"+peer['vpc']['id']) + + MU::Cloud::AWS.createStandardTags(peering_id, region: @config['region'], credentials: @config['credentials']) MU::MommaCat.createTag(peering_id, "Name", peering_name, region: @config['region'], credentials: @config['credentials']) if @config['optional_tags'] @@ -577,24 +597,24 @@ def groom :destination_cidr_block => peer_obj.cloud_desc.cidr_block, :vpc_peering_connection_id => peering_id } - begin - MU.log "Creating peering route to #{peer_obj.cloud_desc.cidr_block} from VPC #{@config['name']}" - resp = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).create_route(my_route_config) - rescue Aws::EC2::Errors::RouteAlreadyExists => e - rtbdesc = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).describe_route_tables( - route_table_ids: [rtb_id] - ).route_tables.first - rtbdesc.routes.each { |r| - if r.destination_cidr_block == peer_obj.cloud_desc.cidr_block - if r.vpc_peering_connection_id != peering_id - MU.log "Attempt to create duplicate route to #{peer_obj.cloud_desc.cidr_block} from VPC #{@config['name']}", MU::ERR, details: r - raise MuError, "Can't create route via #{peering_id}, a route to #{peer_obj.cloud_desc.cidr_block} already exists" - else - break # this is fine, the route simply already exists - end + rtbdesc = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).describe_route_tables( + route_table_ids: [rtb_id] + ).route_tables.first + already_exists = false + rtbdesc.routes.each { |r| + if r.destination_cidr_block == peer_obj.cloud_desc.cidr_block + if r.vpc_peering_connection_id != peering_id + MU.log "Attempt to create duplicate route to #{peer_obj.cloud_desc.cidr_block} from VPC #{@config['name']}", MU::ERR, details: r + raise MuError, "Can't create route via #{peering_id}, a route to #{peer_obj.cloud_desc.cidr_block} already exists" + else + already_exists = true end - } - end + end + } + next if already_exists + + MU.log "Creating peering route to #{peer_obj.cloud_desc.cidr_block} from VPC #{@config['name']}" + resp = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).create_route(my_route_config) } # MU::Cloud::AWS::VPC.listAllSubnetRouteTables begin @@ -673,13 +693,13 @@ def groom rtb['routes'].each { |route| if !route['nat_host_id'].nil? or !route['nat_host_name'].nil? route_config = { - :route_table_id => route_table_id, - :destination_cidr_block => route['destination_network'] + :route_table_id => route_table_id, + :destination_cidr_block => route['destination_network'] } nat_instance = findBastion( - nat_name: route["nat_host_name"], - nat_cloud_id: route["nat_host_id"] + nat_name: route["nat_host_name"], + nat_cloud_id: route["nat_host_id"] ) if nat_instance.nil? raise MuError, "VPC #{vpc_name} is configured to use #{route} as a route, but I can't find a matching bastion host!" @@ -697,12 +717,14 @@ def groom end # Locate an existing VPC or VPCs and return an array containing matching AWS resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @return [Array>]: The cloud provider's complete descriptions of matching VPCs - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, credentials: nil, flags: {}) + # @return [Hash]: The cloud provider's complete descriptions of matching VPCs + def self.find(**args) + cloud_id = args[:cloud_id] + region = args[:region] || MU.curRegion + tag_key = args[:tag_key] || "Name" + tag_value = args[:tag_value] + credentials = args[:credentials] + flags = args[:flags] retries = 0 map = {} @@ -866,7 +888,7 @@ def findNat(nat_cloud_id: nil, nat_filter_key: nil, nat_filter_value: nil, regio # @param nat_tag_value [String]: A cloud provider tag to help identify the resource, used in conjunction with tag_key. # @param nat_ip [String]: An IP address associated with the NAT instance. def findBastion(nat_name: nil, nat_cloud_id: nil, nat_tag_key: nil, nat_tag_value: nil, nat_ip: nil) - nat = nil + deploy_id = nil nat_name = nat_name.to_s if !nat_name.nil? and nat_name.class.to_s == "MU::Config::Tail" nat_ip = nat_ip.to_s if !nat_ip.nil? and nat_ip.class.to_s == "MU::Config::Tail" @@ -1004,8 +1026,6 @@ def self.haveRouteToInstance?(target_instance, region: MU.curRegion, credentials return MU::Cloud::AWS::VPC.have_route_peered_vpc?(my_subnets_key, target_subnets_key, instance_id) end - @route_cache[instance_id] = false - return false end # updates the route table cache (@rtb_cache). @@ -1169,6 +1189,7 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent gwthreads << Thread.new { purge_nat_gateways(noop, vpc_id: vpc.vpc_id, region: region, credentials: credentials) purge_endpoints(noop, vpc_id: vpc.vpc_id, region: region, credentials: credentials) + purge_interfaces(noop, [{name: "vpc-id", values: [vpc.vpc_id]}], region: region, credentials: credentials) } } gwthreads.each { |t| @@ -1232,19 +1253,6 @@ def self.schema(config) def self.validateConfig(vpc, configurator) ok = true - if (!vpc['route_tables'] or vpc['route_tables'].size == 0) and vpc['create_standard_subnets'] - vpc['route_tables'] = [ - { - "name" => "internet", - "routes" => [ { "destination_network" => "0.0.0.0/0", "gateway" => "#INTERNET" } ] - }, - { - "name" => "private", - "routes" => [ { "destination_network" => "0.0.0.0/0", "gateway" => "#NAT" } ] - } - ] - end - if vpc["enable_traffic_logging"] logdesc = { "name" => vpc['name']+"loggroup", @@ -1355,6 +1363,7 @@ def self.validateConfig(vpc, configurator) "name" => route['nat_host_name'] } elsif route['gateway'] == '#NAT' + vpc['create_nat_gateway'] = true private_rtbs << table['name'] elsif route['gateway'] == '#INTERNET' public_rtbs << table['name'] @@ -1400,11 +1409,12 @@ def self.validateConfig(vpc, configurator) if (!vpc['subnets'] or vpc['subnets'].empty?) and vpc['create_standard_subnets'] if vpc['availability_zones'].nil? or vpc['availability_zones'].empty? - vpc['availability_zones'] = MU::Cloud::AWS.listAZs(region: vpc['region']) + vpc['availability_zones'] = MU::Cloud::AWS.listAZs(region: vpc['region'], credentials: vpc['credentials']) else # turn into a hash so we can use list parameters easily vpc['availability_zones'] = vpc['availability_zones'].map { |val| val['zone'] } end + subnets = configurator.divideNetwork(vpc['ip_block'], vpc['availability_zones'].size*vpc['route_tables'].size, 28) ok = false if subnets.nil? @@ -1446,26 +1456,32 @@ def self.validateConfig(vpc, configurator) ok end - # Remove all network interfaces associated with the currently loaded deployment. - # @param noop [Boolean]: If true, will only print what would be done - # @param tagfilters [Array]: EC2 tags to filter against when search for resources to purge - # @param region [String]: The cloud provider region - # @return [void] - def self.purge_interfaces(noop = false, tagfilters = [{name: "tag:MU-ID", values: [MU.deploy_id]}], region: MU.curRegion, credentials: nil) - resp = MU::Cloud::AWS.ec2(credentials: credentials, region: region).describe_network_interfaces( - filters: tagfilters + # List the CIDR blocks to which these VPC has routes. Exclude obvious + # things like +0.0.0.0/0+. + # @param subnets [Array]: Only return the routes relevant to these subnet ids + def routes(subnets: []) + @my_visible_cidrs ||= {} + return @my_visible_cidrs[subnets] if @my_visible_cidrs[subnets] + filters = [{ :name => "vpc-id", :values => [@cloud_id] }] + if subnets and subnets.size > 0 + filters << { :name => "association.subnet-id", :values => subnets } + end + tables = MU::Cloud::AWS.ec2(region: @config['region'], credentials: @config['credentials']).describe_route_tables( + filters: filters ) - ifaces = resp.data.network_interfaces - - return if ifaces.nil? or ifaces.size == 0 - - ifaces.each { |iface| - MU.log "Deleting Network Interface #{iface.network_interface_id}" - MU::Cloud::AWS.ec2(credentials: credentials, region: region).delete_network_interface(network_interface_id: iface.network_interface_id) - } + cidrs = [] + if tables and tables.route_tables + tables.route_tables.each { |rtb| + rtb.routes.each { |route| + next if route.destination_cidr_block == "0.0.0.0/0" + cidrs << route.destination_cidr_block + } + } + end + @my_visible_cidrs[subnets] = cidrs.uniq.sort + @my_visible_cidrs[subnets] end - private # List the route tables for each subnet in the given VPC @@ -1536,12 +1552,12 @@ def createRouteTable(rtb) } end - MU::MommaCat.createStandardTags(route_table_id, credentials: @config['credentials']) + MU::Cloud::AWS.createStandardTags(route_table_id, credentials: @config['credentials']) rtb['routes'].each { |route| if route['nat_host_id'].nil? and route['nat_host_name'].nil? route_config = { - :route_table_id => route_table_id, - :destination_cidr_block => route['destination_network'] + :route_table_id => route_table_id, + :destination_cidr_block => route['destination_network'] } if !route['peer_id'].nil? route_config[:vpc_peering_connection_id] = route['peer_id'] @@ -1566,25 +1582,46 @@ def createRouteTable(rtb) # @return [void] def self.purge_gateways(noop = false, tagfilters = [{name: "tag:MU-ID", values: [MU.deploy_id]}], region: MU.curRegion, credentials: nil) resp = MU::Cloud::AWS.ec2(credentials: credentials, region: region).describe_internet_gateways( - filters: tagfilters + filters: tagfilters ) gateways = resp.data.internet_gateways gateways.each { |gateway| + vpc_id = nil gateway.attachments.each { |attachment| - MU.log "Detaching Internet Gateway #{gateway.internet_gateway_id} from #{attachment.vpc_id}" + vpc_id = attachment.vpc_id + tried_interfaces = false begin + MU.log "Detaching Internet Gateway #{gateway.internet_gateway_id} from #{attachment.vpc_id}" MU::Cloud::AWS.ec2(credentials: credentials, region: region).detach_internet_gateway( - internet_gateway_id: gateway.internet_gateway_id, - vpc_id: attachment.vpc_id + internet_gateway_id: gateway.internet_gateway_id, + vpc_id: attachment.vpc_id ) if !noop + rescue Aws::EC2::Errors::DependencyViolation => e + if !tried_interfaces + purge_interfaces(noop, [{name: "vpc-id", values: [attachment.vpc_id]}], region: region, credentials: credentials) + tried_interfaces = true + sleep 2 + retry + end + MU.log e.message, MU::ERR rescue Aws::EC2::Errors::GatewayNotAttached => e MU.log "Gateway #{gateway.internet_gateway_id} was already detached", MU::WARN end } - MU.log "Deleting Internet Gateway #{gateway.internet_gateway_id}" + + tried_interfaces = false begin + MU.log "Deleting Internet Gateway #{gateway.internet_gateway_id}" MU::Cloud::AWS.ec2(credentials: credentials, region: region).delete_internet_gateway(internet_gateway_id: gateway.internet_gateway_id) if !noop + rescue Aws::EC2::Errors::DependencyViolation => e + if !tried_interfaces and vpc_id + purge_interfaces(noop, [{name: "vpc-id", values: [vpc_id]}], region: region, credentials: credentials) + tried_interfaces = true + sleep 2 + retry + end + MU.log e.message, MU::ERR rescue Aws::EC2::Errors::InvalidInternetGatewayIDNotFound MU.log "Gateway #{gateway.internet_gateway_id} was already destroyed by the time I got to it", MU::WARN end @@ -1774,15 +1811,42 @@ def self.purge_routetables(noop = false, tagfilters = [{name: "tag:MU-ID", value # @return [void] def self.purge_interfaces(noop = false, tagfilters = [{name: "tag:MU-ID", values: [MU.deploy_id]}], region: MU.curRegion, credentials: nil) resp = MU::Cloud::AWS.ec2(credentials: credentials, region: region).describe_network_interfaces( - filters: tagfilters + filters: tagfilters ) ifaces = resp.data.network_interfaces return if ifaces.nil? or ifaces.size == 0 ifaces.each { |iface| - MU.log "Deleting Network Interface #{iface.network_interface_id}" - MU::Cloud::AWS.ec2(credentials: credentials, region: region).delete_network_interface(network_interface_id: iface.network_interface_id) + begin + if iface.attachment and iface.attachment.status == "attached" + MU.log "Detaching Network Interface #{iface.network_interface_id} from #{iface.attachment.instance_owner_id}" + tried_lbs = false + begin + MU::Cloud::AWS.ec2(credentials: credentials, region: region).detach_network_interface(attachment_id: iface.attachment.attachment_id) if !noop + rescue Aws::EC2::Errors::InvalidAttachmentIDNotFound => e + # suits me just fine + rescue Aws::EC2::Errors::AuthFailure => e + if !tried_lbs and iface.attachment.instance_owner_id == "amazon-elb" + MU::Cloud::AWS::LoadBalancer.cleanup( + noop: noop, + region: region, + credentials: credentials, + flags: {"vpc_id" => iface.vpc_id} + ) + tried_lbs = true + retry + end + MU.log e.message, MU::ERR, details: iface.attachment + end + end + MU.log "Deleting Network Interface #{iface.network_interface_id}" + MU::Cloud::AWS.ec2(credentials: credentials, region: region).delete_network_interface(network_interface_id: iface.network_interface_id) if !noop + rescue Aws::EC2::Errors::InvalidNetworkInterfaceIDNotFound => e + # ok then! + rescue Aws::EC2::Errors::InvalidParameterValue => e + MU.log e.message, MU::ERR, details: iface + end } end @@ -1801,25 +1865,32 @@ def self.purge_subnets(noop = false, tagfilters = [{name: "tag:MU-ID", values: [ retries = 0 subnets.each { |subnet| + MU.log "Deleting Subnet #{subnet.subnet_id}" begin if subnet.state != "available" MU.log "Waiting for #{subnet.subnet_id} to be in a removable state...", MU::NOTICE sleep 30 else - MU.log "Deleting Subnet #{subnet.subnet_id}" MU::Cloud::AWS.ec2(credentials: credentials, region: region).delete_subnet(subnet_id: subnet.subnet_id) if !noop end rescue Aws::EC2::Errors::DependencyViolation => e - if retries < 7 - MU.log "#{e.inspect}, retrying in 10s", MU::WARN - sleep 10 + # We're often stuck waiting for an RDS database or something else + # that takes 5-ever to delete. + if retries < 19 + loglevel = (retries > 0 and (retries % 3) == 0) ? MU::NOTICE : MU::DEBUG + MU.log "#{e.message} (retry #{retries.to_s}/20)", loglevel + sleep 30 + retries = retries + 1 + retry + elsif retries < 20 + MU.log "#{e.message} (final attempt)", MU::WARN + sleep 60 retries = retries + 1 retry else raise e end rescue Aws::EC2::Errors::InvalidSubnetIDNotFound - MU.log "Subnet #{subnet.subnet_id} disappeared before I could remove it", MU::WARN next end while subnet.state != "available" } @@ -1918,19 +1989,28 @@ def self.purge_vpcs(noop = false, tagfilters = [{name: "tag:MU-ID", values: [MU. end } - MU.log "Deleting VPC #{vpc.vpc_id}" retries = 0 begin + MU.log "Deleting VPC #{vpc.vpc_id}" MU::Cloud::AWS.ec2(credentials: credentials, region: region).delete_vpc(vpc_id: vpc.vpc_id) if !noop rescue Aws::EC2::Errors::InvalidVpcIDNotFound MU.log "VPC #{vpc.vpc_id} has already been deleted", MU::WARN rescue Aws::EC2::Errors::DependencyViolation => e - MU.log "Couldn't delete VPC #{vpc.vpc_id} from #{region}: #{e.inspect}", MU::ERR#, details: caller if retries < 5 + MU.log "#{vpc.vpc_id} in #{region} had hidden dependencies, will try to remove them", MU::NOTICE retries += 1 + # fry some common rogue resources + MU::Cloud::AWS::FirewallRule.cleanup( + noop: noop, + region: region, + credentials: credentials, + flags: { "vpc_id" => vpc.vpc_id } + ) + purge_gateways(noop, tagfilters, region: region, credentials: credentials) sleep 10 retry else + MU.log "Failed to remove #{vpc.vpc_id} in #{region}: #{e.message}", MU::ERR next end end diff --git a/modules/mu/clouds/azure.rb b/modules/mu/clouds/azure.rb index d76ea20ec..918c96288 100644 --- a/modules/mu/clouds/azure.rb +++ b/modules/mu/clouds/azure.rb @@ -12,27 +12,136 @@ # See the License for the specific language governing permissions and # limitations under the License. -require "net/http" -require 'net/https' -require 'multi_json' -require 'stringio' +require 'open-uri' +require 'json' +require 'timeout' module MU class Cloud # Support for Microsoft Azure as a provisioning layer. class Azure @@is_in_azure = nil + @@metadata = nil + @@acct_to_profile_map = nil #WHAT EVEN IS THIS? + @@myRegion_var = nil + @@default_subscription = nil + @@regions = [] - # Alias for #{MU::Cloud::AWS.hosted?} - def self.hosted - MU::Cloud::Azure.hosted? + # Module used by {MU::Cloud} to insert additional instance methods into + # instantiated resources in this cloud layer. + module AdditionalResourceMethods + end + + # Exception class for exclusive use by {MU::Cloud::Azure::SDKClient::ClientCallWrapper} + class APIError < MU::MuError + end + + # Return a random Azure-valid GUID, because for some baffling reason some + # API calls expect us to roll our own. + def self.genGUID + hexchars = Array("a".."f") + Array(0..9) + guid_chunks = [] + [8, 4, 4, 4, 12].each { |count| + guid_chunks << Array.new(count) { hexchars.sample }.join + } + guid_chunks.join("-") end + # A hook that is always called just before any of the instance method of + # our resource implementations gets invoked, so that we can ensure that + # repetitive setup tasks (like resolving +:resource_group+ for Azure + # resources) have always been done. + # @param cloudobj [MU::Cloud] + # @param deploy [MU::MommaCat] + def self.resourceInitHook(cloudobj, deploy) + class << self + attr_reader :resource_group + end + return if !cloudobj + + rg = if !deploy + return if !hosted? + MU.myInstanceId.resource_group + else + region = cloudobj.config['region'] || MU::Cloud::Azure.myRegion(cloudobj.config['credentials']) + deploy.deploy_id+"-"+region.upcase + end + + cloudobj.instance_variable_set(:@resource_group, rg) + + end + + # Any cloud-specific instance methods we require our resource implementations to have, above and beyond the ones specified by {MU::Cloud} + # @return [Array] + def self.required_instance_methods + [:resource_group] + end + + # Stub class to represent Azure's resource identifiers, which look like: + # /subscriptions/3d20ddd8-4652-4074-adda-0d127ef1f0e0/resourceGroups/mu/providers/Microsoft.Network/virtualNetworks/mu-vnet + # Various API calls need chunks of this in different contexts, and this + # full string is necessary to guarantee that a +cloud_id+ is a unique + # identifier for a given resource. So we'll use this object of our own + # devising to represent it. + class Id + attr_reader :subscription + attr_reader :resource_group + attr_reader :provider + attr_reader :type + attr_reader :name + attr_reader :raw + + # The name of the attribute on a cloud object from this provider which + # has the provider's long-form cloud identifier (Google Cloud URL, + # Amazon ARN, etc). + def self.idattr + :id + end + + def initialize(*args) + if args.first.is_a?(String) + @raw = args.first + junk, junk, @subscription, junk, @resource_group, junk, @provider, @resource_type, @name = @raw.split(/\//) + if @subscription.nil? or @resource_group.nil? or @provider.nil? or @resource_type.nil? or @name.nil? + # Not everything has a resource group + if @raw.match(/^\/subscriptions\/#{Regexp.quote(@subscription)}\/providers/) + junk, junk, @subscription, junk, @provider, @resource_type, @name = @raw.split(/\//) + if @subscription.nil? or @provider.nil? or @resource_type.nil? or @name.nil? + raise MuError, "Failed to parse Azure resource id string #{@raw} (got subscription: #{@subscription}, provider: #{@provider}, resource_type: #{@resource_type}, name: #{@name}" + end + + else + raise MuError, "Failed to parse Azure resource id string #{@raw} (got subscription: #{@subscription}, resource_group: #{@resource_group}, provider: #{@provider}, resource_type: #{@resource_type}, name: #{@name}" + end + end + else + args.each { |arg| + if arg.is_a?(Hash) + arg.each_pair { |k, v| + self.instance_variable_set(("@"+k.to_s).to_sym, v) + } + end + } + + if @resource_group.nil? or @name.nil? + raise MuError, "Failed to extract at least name and resource_group fields from #{args.flatten.join(", ").to_s}" + end + end + end + + # Return a reasonable string representation of this {MU::Cloud::Azure::Id} + def to_s + @name + end + end + + +# UTILITY METHODS # Determine whether we (the Mu master, presumably) are hosted in Azure. # @return [Boolean] def self.hosted? - if $MU_CFG.has_key?("azure_is_hosted") - @@is_in_aws = $MU_CFG["azure_is_hosted"] + if $MU_CFG and $MU_CFG.has_key?("azure_is_hosted") + @@is_in_azure = $MU_CFG["azure_is_hosted"] return $MU_CFG["azure_is_hosted"] end @@ -56,52 +165,330 @@ def self.hosted? false end + # If we reside in this cloud, return the VPC in which we, the Mu Master, reside. + # @return [MU::Cloud::VPC] + def self.myVPC + return nil if !hosted? +# XXX do me + end + + # Alias for #{MU::Cloud::Azure.hosted?} + def self.hosted + return MU::Cloud::Azure.hosted? + end + + # If we're running this cloud, return the $MU_CFG blob we'd use to + # describe this environment as our target one. def self.hosted_config - "TODO" + return nil if !hosted? + region = get_metadata()['compute']['location'] + subscription = get_metadata()['compute']['subscriptionId'] + { + "region" => region, + "subscriptionId" => subscription + } end - # Any cloud-specific instance methods we require our resource implementations to have, above and beyond the ones specified by {MU::Cloud} - # @return [Array] - def self.required_instance_methods - [] + # Azure's API response objects don't implement +to_h+, so we'll wing it + # ourselves + # @param struct [MsRestAzure] + # @return [Hash] + def self.respToHash(struct) + hash = {} + struct.class.instance_methods(false).each { |m| + next if m.to_s.match(/=$/) + hash[m.to_s] = struct.send(m) + } + struct.instance_variables.each { |a| + hash[a.to_s.sub(/^@/, "")] = struct.instance_variable_get(a) + } + hash end - def self.myRegion - "TODO" + # Method that returns the default Azure region for this Mu Master + # @return [string] + def self.myRegion(credentials = nil) + if @@myRegion_var + return @@myRegion_var + end + + cfg = credConfig(credentials) + + @@myRegion_var = if cfg['default_region'] + cfg['default_region'] + elsif MU::Cloud::Azure.hosted? + # IF WE ARE HOSTED IN AZURE CHECK FOR THE REGION OF THE INSTANCE + metadata = get_metadata() + metadata['compute']['location'] + else + "eastus" + end + + return @@myRegion_var + end + + # lookup the default subscription that will be used by methods + def self.default_subscription(credentials = nil) + cfg = credConfig(credentials) + if @@default_subscription.nil? + if cfg['subscription'] + # MU.log "Found default subscription in mu.yml. Using that..." + @@default_subscription = cfg['subscription'] + + elsif listSubscriptions().length == 1 + #MU.log "Found a single subscription on your account. Using that... (This may be incorrect)", MU::WARN, details: e.message + @@default_subscription = listSubscriptions()[0] + + elsif MU::Cloud::Azure.hosted? + #MU.log "Found a subscriptionID in my metadata. Using that... (This may be incorrect)", MU::WARN, details: e.message + @@default_subscription = get_metadata()['compute']['subscriptionId'] + + else + raise MuError, "Default Subscription was not found. Please run mu-configure to setup a default subscription" + end + end + + return @@default_subscription + end + + # List visible Azure regions + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # return [Array] + def self.listRegions(credentials: nil) + cfg = credConfig(credentials) + return nil if !cfg and !hosted? + subscription = cfg['subscription'] + subscription ||= default_subscription() + + if @@regions.length() > 0 && subscription == default_subscription() + return @@regions + end + + begin + sdk_response = MU::Cloud::Azure.subs(credentials: credentials).subscriptions().list_locations(subscription) + rescue Exception => e + MU.log e.inspect, MU::ERR, details: e.backtrace + #pp "Error Getting the list of regions from Azure" #TODO: SWITCH THIS TO MU LOG + return @@regions if @@regions and @@regions.size > 0 + raise e + end + + sdk_response.value.each do | region | + @@regions.push(region.name) + end + + return @@regions end - def self.listRegions(credentials = nil) - [] + # List subscriptions visible to the given credentials + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # return [Array] + def self.listSubscriptions(credentials = nil) + subscriptions = [] + + sdk_response = MU::Cloud::Azure.subs(credentials: credentials).subscriptions().list + + sdk_response.each do |subscription| + subscriptions.push(subscription.subscription_id) + end + + return subscriptions end + # List the Availability Zones associated with a given Azure region. + # If no region is given, search the one in which this MU master + # server resides (if it resides in this cloud provider's ecosystem). + # @param region [String]: The region to search. + # @return [Array]: The Availability Zones in this region. def self.listAZs(region = nil) - [] + az_list = ['1', '2', '3'] + + # Pulled from this chart: https://docs.microsoft.com/en-us/azure/availability-zones/az-overview#services-support-by-region + az_enabled_regions = ['centralus', 'eastus', 'eastus2', 'westus2', 'francecentral', 'northeurope', 'uksouth', 'westeurope', 'japaneast', 'southeastasia'] + + if not az_enabled_regions.include?(region) + az_list = [] + end + + return az_list end + # A non-working example configuration def self.config_example - {} + sample = hosted_config + sample ||= { + "region" => "eastus", + "subscriptionId" => "99999999-9999-9999-9999-999999999999", + } + + sample["credentials_file"] = "~/.azure/credentials" + sample["log_bucket_name"] = "my-mu-s3-bucket" + sample end - def self.writeDeploySecret - "TODO" + # Do cloud-specific deploy instantiation tasks, such as copying SSH keys + # around, sticking secrets in buckets, creating resource groups, etc + # @param deploy [MU::MommaCat] + def self.initDeploy(deploy) + deploy.credsUsed.each { |creds| + next if !credConfig(creds) + listRegions.each { |region| + next if !deploy.regionsUsed.include?(region) + begin + createResourceGroup(deploy.deploy_id+"-"+region.upcase, region, credentials: creds) + rescue ::MsRestAzure::AzureOperationError + end + } + } end + @@rg_semaphore = Mutex.new + + # Purge cloud-specific deploy meta-artifacts (SSH keys, resource groups, + # etc) + # @param deploy_id [String] + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + def self.cleanDeploy(deploy_id, credentials: nil, noop: false) + threads = [] + + @@rg_semaphore.synchronize { + MU::Cloud::Azure.resources(credentials: credentials).resource_groups.list.each { |rg| + if rg.tags and rg.tags["MU-ID"] == deploy_id + threads << Thread.new(rg) { |rg_obj| + Thread.abort_on_exception = false + MU.log "Removing resource group #{rg_obj.name} from #{rg_obj.location}" + if !noop + MU::Cloud::Azure.resources(credentials: credentials).resource_groups.delete(rg_obj.name) + end + } + end + } + threads.each { |t| + t.join + } + } + end + + # Azure resources are deployed into a containing artifact called a Resource Group, which we will map 1:1 with Mu deployments + # @param name [String]: A name for this resource group + # @param region [String]: The region in which to create this resource group + def self.createResourceGroup(name, region, credentials: nil) + rg_obj = MU::Cloud::Azure.resources(:ResourceGroup).new + rg_obj.location = region + rg_obj.tags = MU::MommaCat.listStandardTags + rg_obj.tags.reject! { |k, v| v.nil? } + + MU::Cloud::Azure.resources(credentials: credentials).resource_groups.list.each { |rg| + if rg.name == name and rg.location == region and rg.tags == rg_obj.tags + MU.log "Resource group #{name} already exists in #{region}", MU::DEBUG, details: rg_obj + return rg # already exists? Do nothing + end + } + MU.log "Configuring resource group #{name} in #{region}", details: rg_obj + MU::Cloud::Azure.resources(credentials: credentials).resource_groups.create_or_update( + name, + rg_obj + ) + end + + # Plant a Mu deploy secret into a storage bucket somewhere for so our kittens can consume it + # @param deploy_id [String]: The deploy for which we're writing the secret + # @param value [String]: The contents of the secret + def self.writeDeploySecret(deploy_id, value, name = nil, credentials: nil) +# XXX this ain't it hoss + end + + # Return the name strings of all known sets of credentials for this cloud + # @return [Array] def self.listCredentials - "TODO" + if !$MU_CFG['azure'] + return hosted? ? ["#default"] : nil + end + + $MU_CFG['azure'].keys end - def self.credConfig - "TODO" + # Return what we think of as a cloud object's habitat. If this is not + # applicable, such as for a {Habitat} or {Folder}, returns nil. + # @param cloudobj [MU::Cloud::Azure]: The resource from which to extract the habitat id + # @return [String,nil] + def self.habitat(cloudobj, nolookup: false, deploy: nil) + nil # we don't know how to do anything with subscriptions yet, really end - def self.listInstanceTypes - "TODO" + @@my_hosted_cfg = nil + # Return the $MU_CFG data associated with a particular profile/name/set of + # credentials. If no account name is specified, will return one flagged as + # default. Returns nil if Azure is not configured. Throws an exception if + # an account name is specified which does not exist. + # @param name [String]: The name of the key under 'azure' in mu.yaml to return + # @return [Hash,nil] + def self.credConfig (name = nil, name_only: false) + if !$MU_CFG['azure'] or !$MU_CFG['azure'].is_a?(Hash) or $MU_CFG['azure'].size == 0 + return @@my_hosted_cfg if @@my_hosted_cfg + + if hosted? + @@my_hosted_cfg = hosted_config + return name_only ? "#default" : @@my_hosted_cfg + end + + return nil + end + + if name.nil? + $MU_CFG['azure'].each_pair { |set, cfg| + if cfg['default'] + return name_only ? set : cfg + end + } + else + if $MU_CFG['azure'][name] + return name_only ? name : $MU_CFG['azure'][name] +# elsif @@acct_to_profile_map[name.to_s] +# return name_only ? name : @@acct_to_profile_map[name.to_s] + end +# XXX whatever process might lead us to populate @@acct_to_profile_map with some mappings, like projectname -> account profile, goes here + return nil + end + end - + + @@instance_types = nil + # Query the Azure API for a list of valid instance types. + # @param region [String]: Supported machine types can vary from region to region, so we look for the set we're interested in specifically + # @return [Hash] + def self.listInstanceTypes(region = self.myRegion) + return @@instance_types if @@instance_types and @@instance_types[region] + if !MU::Cloud::Azure.default_subscription() + return {} + end + + @@instance_types ||= {} + @@instance_types[region] ||= {} + result = MU::Cloud::Azure.compute.virtual_machine_sizes.list(region) + raise MuError, "Failed to fetch Azure instance type list" if !result + result.value.each { |type| + @@instance_types[region][type.name] ||= {} + @@instance_types[region][type.name]["memory"] = sprintf("%.1f", type.memory_in_mb/1024.0).to_f + @@instance_types[region][type.name]["vcpu"] = type.number_of_cores.to_f + @@instance_types[region][type.name]["ecu"] = type.number_of_cores + } + + @@instance_types + end + + # Resolve the administrative Cloud Storage bucket for a given credential + # set, or return a default. + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [String] def self.adminBucketName(credentials = nil) "TODO" end + # Resolve the administrative Cloud Storage bucket for a given credential + # set, or return a default. + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [String] def self.adminBucketUrl(credentials = nil) "TODO" end @@ -109,28 +496,575 @@ def self.adminBucketUrl(credentials = nil) #END REQUIRED METHODS - # Fetch an Azure instance metadata parameter (example: public-ipv4). - # @return [String, nil] - def self.get_metadata() - base_url = "http://169.254.169.254/metadata/instance" - api_version = '2017-12-01' + # Fetch (ALL) Azure instance metadata + # @return [Hash, nil] + def self.get_metadata(svc = "instance", api_version = "2017-08-01", args: {}, debug: false) + loglevel = debug ? MU::NOTICE : MU::DEBUG + return @@metadata if svc == "instance" and @@metadata + base_url = "http://169.254.169.254/metadata/#{svc}" + args["api-version"] = api_version + arg_str = args.keys.map { |k| k.to_s+"="+args[k].to_s }.join("&") + begin - response = nil - Timeout.timeout(1) do - response = MultiJson.load(open("#{base_url}/?api-version=#{ api_version }", "Metadata" => "true").read) + Timeout.timeout(2) do + resp = JSON.parse(open("#{base_url}/?#{arg_str}","Metadata"=>"true").read) + MU.log "curl -H Metadata:true "+"#{base_url}/?#{arg_str}", loglevel, details: resp + if svc != "instance" + return resp + else + @@metadata = resp + end end + return @@metadata - response - rescue OpenURI::HTTPError, Timeout::Error, SocketError, Errno::ENETUNREACH, Net::HTTPServerException, Errno::EHOSTUNREACH => e - # This is normal on machines checking to see if they're AWS-hosted - logger = MU::Logger.new - logger.log "Failed metadata request #{base_url}/: #{e.inspect}", MU::DEBUG + rescue Timeout::Error => e + # MU.log "Timeout querying Azure Metadata" + return nil + rescue + # MU.log "Failed to get Azure MetaData." return nil end end - end - end -end + # Map our SDK authorization options from MU configuration into an options + # hash that Azure understands. Raises an exception if any fields aren't + # available. + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [Hash] + def self.getSDKOptions(credentials = nil) + cfg = credConfig(credentials) + + if cfg and MU::Cloud::Azure.hosted? + token = MU::Cloud::Azure.get_metadata("identity/oauth2/token", "2018-02-01", args: { "resource"=>"https://management.azure.com/" }) + if !token + MU::Cloud::Azure.get_metadata("identity/oauth2/token", "2018-02-01", args: { "resource"=>"https://management.azure.com/" }, debug: true) + raise MuError, "Failed to get machine oauth token" + end + machine = MU::Cloud::Azure.get_metadata + return { + credentials: MsRest::TokenCredentials.new(token["access_token"]), + client_id: token["client_id"], + subscription: machine["compute"]["subscriptionId"], + subscription_id: machine["compute"]["subscriptionId"] + } + end + return nil if !cfg + map = { #... from mu.yaml-ese to Azure SDK-ese + "directory_id" => :tenant_id, + "client_id" => :client_id, + "client_secret" => :client_secret, + "subscription" => :subscription_id + } + + options = {} + + map.each_pair { |k, v| + options[v] = cfg[k] if cfg[k] + } + + if cfg['credentials_file'] + file = File.open cfg['credentials_file'] + credfile = JSON.load file + map.each_pair { |k, v| + options[v] = credfile[k] if credfile[k] + } + end + + missing = [] + map.values.each { |v| + missing << v if !options[v] + } + + if missing.size > 0 + if (!credentials or credentials == "#default") and hosted? + # Let the SDK try to use machine credentials + return nil + end + raise MuError, "Missing fields while trying to load Azure SDK options for credential set #{credentials ? credentials : "" }: #{missing.map { |m| m.to_s }.join(", ")}" + end + + MU.log "Loaded credential set #{credentials ? credentials : "" }", MU::DEBUG, details: options + + return options + end + + # Find or allocate a static public IP address resource + # @param resource_group [String] + # @param name [String] + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @param region [String] + # @param tags [Hash] + # @return [Azure::Network::Mgmt::V2019_02_01::Models::PublicIPAddress] + def self.fetchPublicIP(resource_group, name, credentials: nil, region: nil, tags: nil) + if !name or !resource_group + raise MuError, "Must supply resource_group and name to create or retrieve an Azure PublicIPAddress" + end + region ||= MU::Cloud::Azure.myRegion(credentials) + + resp = MU::Cloud::Azure.network(credentials: credentials).public_ipaddresses.get(resource_group, name) + if !resp + ip_obj = MU::Cloud::Azure.network(:PublicIPAddress).new + ip_obj.location = region + ip_obj.tags = tags if tags + ip_obj.public_ipallocation_method = "Dynamic" + MU.log "Allocating PublicIpAddress #{name}", details: ip_obj + resp = MU::Cloud::Azure.network(credentials: credentials).public_ipaddresses.create_or_update(resource_group, name, ip_obj) + end + + resp + end + +# BEGIN SDK STUBS +# + # Azure Subscription Manager API + # @param model []: If specified, will return the class ::Azure::Apis::Subscriptions::Mgmt::V2015_11_01::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.subs(model = nil, alt_object: nil, credentials: nil, model_version: "V2015_11_01") + require 'azure_mgmt_subscriptions' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("Subscriptions").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@subscriptions_api[credentials] ||= MU::Cloud::Azure::SDKClient.new(api: "Subscriptions", credentials: credentials, subclass: alt_object) + end + + return @@subscriptions_api[credentials] + end + + # An alternative version of the Azure Subscription Manager API, which appears to support subscription creation + # @param model []: If specified, will return the class ::Azure::Apis::Subscriptions::Mgmt::V2018_03_01_preview::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.subfactory(model = nil, alt_object: nil, credentials: nil, model_version: "V2018_03_01_preview") + require 'azure_mgmt_subscriptions' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("Subscriptions").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@subscriptions_factory_api[credentials] ||= MU::Cloud::Azure::SDKClient.new(api: "Subscriptions", credentials: credentials, profile: "V2018_03_01_preview", subclass: alt_object) + end + + return @@subscriptions_factory_api[credentials] + end + + # The Azure Compute API + # @param model []: If specified, will return the class ::Azure::Apis::Compute::Mgmt::V2019_04_01::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.compute(model = nil, alt_object: nil, credentials: nil, model_version: "V2019_03_01") + require 'azure_mgmt_compute' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("Compute").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@compute_api[credentials] ||= MU::Cloud::Azure::SDKClient.new(api: "Compute", credentials: credentials, subclass: alt_object) + end + + return @@compute_api[credentials] + end + + # The Azure Network API + # @param model []: If specified, will return the class ::Azure::Apis::Network::Mgmt::V2019_02_01::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.network(model = nil, alt_object: nil, credentials: nil, model_version: "V2019_02_01") + require 'azure_mgmt_network' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("Network").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@network_api[credentials] ||= MU::Cloud::Azure::SDKClient.new(api: "Network", credentials: credentials, subclass: alt_object) + end + + return @@network_api[credentials] + end + + # The Azure Storage API + # @param model []: If specified, will return the class ::Azure::Apis::Storage::Mgmt::V2019_04_01::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.storage(model = nil, alt_object: nil, credentials: nil, model_version: "V2019_04_01") + require 'azure_mgmt_storage' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("Storage").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@storage_api[credentials] ||= MU::Cloud::Azure::SDKClient.new(api: "Storage", credentials: credentials, subclass: alt_object) + end + + return @@storage_api[credentials] + end + + # The Azure ApiManagement API + # @param model []: If specified, will return the class ::Azure::Apis::ApiManagement::Mgmt::V2019_01_01::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.apis(model = nil, alt_object: nil, credentials: nil, model_version: "V2019_01_01") + require 'azure_mgmt_api_management' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("ApiManagement").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@apis_api[credentials] ||= MU::Cloud::Azure::SDKClient.new(api: "ApiManagement", credentials: credentials, subclass: alt_object) + end + + return @@apis_api[credentials] + end + + # The Azure MarketplaceOrdering API + # @param model []: If specified, will return the class ::Azure::Apis::MarketplaceOrdering::Mgmt::V2015_06_01::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.marketplace(model = nil, alt_object: nil, credentials: nil, model_version: "V2015_06_01") + require 'azure_mgmt_marketplace_ordering' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("Resources").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@marketplace_api[credentials] ||= MU::Cloud::Azure::SDKClient.new(api: "MarketplaceOrdering", credentials: credentials, subclass: alt_object) + end + + return @@marketplace_api[credentials] + end + + # The Azure Resources API + # @param model []: If specified, will return the class ::Azure::Apis::Resources::Mgmt::V2018_05_01::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.resources(model = nil, alt_object: nil, credentials: nil, model_version: "V2018_05_01") + require 'azure_mgmt_resources' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("Resources").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@resources_api[credentials] ||= MU::Cloud::Azure::SDKClient.new(api: "Resources", credentials: credentials, subclass: alt_object) + end + + return @@resources_api[credentials] + end + + # The Azure Features API + # @param model []: If specified, will return the class ::Azure::Apis::Features::Mgmt::V2015_12_01::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.features(model = nil, alt_object: nil, credentials: nil, model_version: "V2015_12_01") + require 'azure_mgmt_features' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("Features").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@features_api[credentials] ||= MU::Cloud::Azure::SDKClient.new(api: "Features", credentials: credentials, subclass: alt_object) + end + + return @@features_api[credentials] + end + + # The Azure ContainerService API + # @param model []: If specified, will return the class ::Azure::Apis::ContainerService::Mgmt::V2019_04_01::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.containers(model = nil, alt_object: nil, credentials: nil, model_version: "V2019_04_01") + require 'azure_mgmt_container_service' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("ContainerService").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@containers_api[credentials] ||= MU::Cloud::Azure::SDKClient.new(api: "ContainerService", credentials: credentials, subclass: alt_object) + end + + return @@containers_api[credentials] + end + + # The Azure ManagedServiceIdentity API + # @param model []: If specified, will return the class ::Azure::Apis::ManagedServiceIdentity::Mgmt::V2015_08_31_preview::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.serviceaccts(model = nil, alt_object: nil, credentials: nil, model_version: "V2015_08_31_preview") + require 'azure_mgmt_msi' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("ManagedServiceIdentity").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@service_identity_api[credentials] ||= MU::Cloud::Azure::SDKClient.new(api: "ManagedServiceIdentity", credentials: credentials, subclass: alt_object) + end + + return @@service_identity_api[credentials] + end + + # The Azure Authorization API + # @param model []: If specified, will return the class ::Azure::Apis::Authorization::Mgmt::V2015_07_01::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.authorization(model = nil, alt_object: nil, credentials: nil, model_version: "V2015_07_01", endpoint_profile: "Latest") + require 'azure_mgmt_authorization' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("Authorization").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@authorization_api[credentials] ||= {} + @@authorization_api[credentials][endpoint_profile] ||= MU::Cloud::Azure::SDKClient.new(api: "Authorization", credentials: credentials, subclass: "AuthorizationManagementClass", profile: endpoint_profile) + end + + return @@authorization_api[credentials][endpoint_profile] + end + + # The Azure Billing API + # @param model []: If specified, will return the class ::Azure::Apis::Billing::Mgmt::V2018_03_01_preview::Models::model instead of an API client instance + # @param model_version [String]: Use an alternative model version supported by the SDK when requesting a +model+ + # @param alt_object [String]: Return an instance of something other than the usual API client object + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + # @return [MU::Cloud::Azure::SDKClient] + def self.billing(model = nil, alt_object: nil, credentials: nil, model_version: "V2018_03_01_preview") + require 'azure_mgmt_billing' + + if model and model.is_a?(Symbol) + return Object.const_get("Azure").const_get("Billing").const_get("Mgmt").const_get(model_version).const_get("Models").const_get(model) + else + @@billing_api[credentials] ||= MU::Cloud::Azure::SDKClient.new(api: "Billing", credentials: credentials, subclass: alt_object) + end + + return @@billing_api[credentials] + end + + # Make sure that a provider is enabled ("Registered" in Azure-ese). + # @param provider [String]: Provider name, typically formatted like +Microsoft.ContainerService+ + # @param force [Boolean]: Run the operation even if the provider already appears to be enabled + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + def self.ensureProvider(provider, force: false, credentials: nil) + state = MU::Cloud::Azure.resources(credentials: credentials).providers.get(provider) + if state.registration_state != "Registered" or force + begin + if state.registration_state == "NotRegistered" or force + MU.log "Registering Provider #{provider}", MU::NOTICE + MU::Cloud::Azure.resources(credentials: credentials).providers.register(provider) + force = false + sleep 30 + elsif state.registration_state == "Registering" + MU.log "Waiting for Provider #{provider} to finish registering", MU::NOTICE, details: state.registration_state + sleep 30 + end + state = MU::Cloud::Azure.resources(credentials: credentials).providers.get(provider) + end while state and state.registration_state != "Registered" + end + end + + # Make sure that a feature is enabled ("Registered" in Azure-ese), usually invoked for preview features which are off by default. + # @param feature_string [String]: The name of a feature, such as +WindowsPreview+ + # @param credentials [String]: The credential set (subscription, effectively) in which to operate + def self.ensureFeature(feature_string, credentials: nil) + provider, feature = feature_string.split(/\//) + feature_state = MU::Cloud::Azure.features(credentials: credentials).features.get(provider, feature) + changed = false + begin + if feature_state + if feature_state.properties.state == "Registering" + MU.log "Waiting for Feature #{provider}/#{feature} to finish registering", MU::NOTICE, details: feature_state.properties.state + sleep 30 + elsif feature_state.properties.state == "NotRegistered" + MU.log "Registering Feature #{provider}/#{feature}", MU::NOTICE + MU::Cloud::Azure.features(credentials: credentials).features.register(provider, feature) + changed = true + sleep 30 + else + MU.log "#{provider}/#{feature} registration state: #{feature_state.properties.state}", MU::DEBUG + end + feature_state = MU::Cloud::Azure.features(credentials: credentials).features.get(provider, feature) + end + end while feature_state and feature_state.properties.state != "Registered" + ensureProvider(provider, credentials: credentials, force: true) if changed + end + +# END SDK STUBS + +# BEGIN SDK CLIENT + private + + @@authorization_api = {} + @@subscriptions_api = {} + @@subscriptions_factory_api = {} + @@compute_api = {} + @@billing_api = {} + @@apis_api = {} + @@network_api = {} + @@storage_api = {} + @@resources_api = {} + @@containers_api = {} + @@features_api = {} + @@apis_api = {} + @@marketplace_api = {} + @@service_identity_api = {} + + # Generic wrapper for connections to Azure APIs + class SDKClient + @api = nil + @credentials = nil + @cred_hash = nil + @wrappers = {} + + attr_reader :issuer + attr_reader :subclass + attr_reader :api + + def initialize(api: "Compute", credentials: nil, profile: "Latest", subclass: nil) + subclass ||= api.sub(/s$/, '')+"Client" + @subclass = subclass + @wrapper_semaphore = Mutex.new + @wrapper_semaphore.synchronize { + @wrappers ||= {} + } + + @credentials = MU::Cloud::Azure.credConfig(credentials, name_only: true) + @cred_hash = MU::Cloud::Azure.getSDKOptions(credentials) + if !@cred_hash + raise MuError, "Failed to load Azure credentials #{credentials ? credentials : ""}" + end + + # There seem to be multiple ways to get at clients, and different + # profiles available depending which way you do it, so... try that? + stdpath = "::Azure::#{api}::Profiles::#{profile}::Mgmt::Client" + begin + # Standard approach: get a client from a canned, approved profile + @api = Object.const_get(stdpath).new(@cred_hash) + rescue NameError => e + raise e if !@cred_hash[:client_secret] + # Weird approach: generate our own credentials object and invoke a + # client directly from a particular model profile + token_provider = MsRestAzure::ApplicationTokenProvider.new( + @cred_hash[:tenant_id], + @cred_hash[:client_id], + @cred_hash[:client_secret] + ) + @cred_obj = MsRest::TokenCredentials.new(token_provider) + begin + modelpath = "::Azure::#{api}::Mgmt::#{profile}::#{@subclass}" + @api = Object.const_get(modelpath).new(@cred_obj) + rescue NameError => e + raise MuError, "Unable to locate a profile #{profile} of Azure API #{api}. I tried:\n#{stdpath}\n#{modelpath}" + end + end + end + + # For method calls into the Azure API + # @param method_sym [Symbol] + # @param arguments [Array] + def method_missing(method_sym, *arguments) + aoe_orig = Thread.abort_on_exception + Thread.abort_on_exception = false + @wrapper_semaphore.synchronize { + return @wrappers[method_sym] if @wrappers[method_sym] + } + # there's a low-key race condition here, but it's harmless and I'm + # trying to pin down an odd deadlock condition on cleanup calls + if !arguments.nil? and arguments.size == 1 + retval = @api.method(method_sym).call(arguments[0]) + elsif !arguments.nil? and arguments.size > 0 + retval = @api.method(method_sym).call(*arguments) + else + retval = @api.method(method_sym).call + end + deep_retval = ClientCallWrapper.new(retval, method_sym.to_s, self) + @wrapper_semaphore.synchronize { + @wrappers[method_sym] ||= deep_retval + } + Thread.abort_on_exception = aoe_orig + return @wrappers[method_sym] + end + + # The Azure SDK embeds several "sub-APIs" in each SDK client, and most + # API calls are made from these second-tier objects. We add an extra + # wrapper layer for these so that we can gracefully handle errors, + # retries, etc. + class ClientCallWrapper + + def initialize(myobject, myname, parent) + @myobject = myobject + @myname = myname + @parent = parent + @parentname = parent.subclass + end + + # For method calls into the Azure API + # @param method_sym [Symbol] + # @param arguments [Array] + def method_missing(method_sym, *arguments) + MU.log "Calling #{@parentname}.#{@myname}.#{method_sym.to_s}", MU::DEBUG, details: arguments + begin + if !arguments.nil? and arguments.size == 1 + retval = @myobject.method(method_sym).call(arguments[0]) + elsif !arguments.nil? and arguments.size > 0 + retval = @myobject.method(method_sym).call(*arguments) + else + retval = @myobject.method(method_sym).call + end + rescue ::Net::ReadTimeout, ::Faraday::TimeoutError => e + sleep 5 + retry + rescue ::MsRestAzure::AzureOperationError, ::MsRest::HttpOperationError => e + MU.log "Error calling #{@parent.api.class.name}.#{@myname}.#{method_sym.to_s}", MU::DEBUG, details: arguments + begin + parsed = JSON.parse(e.message) + if parsed["response"] and parsed["response"]["body"] + response = JSON.parse(parsed["response"]["body"]) + err = if response["code"] and response["message"] + response + elsif response["error"] and response["error"]["code"] and + response["error"]["message"] + response["error"] + end + if err + if method_sym == :get and + ["ResourceNotFound", "NotFound"].include?(err["code"]) + return nil + elsif err["code"] == "AnotherOperationInProgress" + sleep 10 + retry + end + + MU.log "#{@parent.api.class.name}.#{@myname}.#{method_sym.to_s} returned '"+err["code"]+"' - "+err["message"], MU::WARN, details: caller + MU.log e.backtrace[0], MU::WARN, details: parsed + raise MU::Cloud::Azure::APIError, err["code"]+": "+err["message"]+" (call was #{@parent.api.class.name}.#{@myname}.#{method_sym.to_s})" + end + end + rescue JSON::ParserError + end +# MU.log e.inspect, MU::ERR, details: caller +# MU.log e.message, MU::ERR, details: @parent.credentials + end + + retval + end + + end + + end +# END SDK CLIENT + end + end +end diff --git a/modules/mu/clouds/azure/container_cluster.rb b/modules/mu/clouds/azure/container_cluster.rb new file mode 100644 index 000000000..a9cb2f7bd --- /dev/null +++ b/modules/mu/clouds/azure/container_cluster.rb @@ -0,0 +1,413 @@ +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module MU + class Cloud + class Azure + # A Kubernetes cluster as configured in {MU::Config::BasketofKittens::container_clusters} + class ContainerCluster < MU::Cloud::ContainerCluster + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + + # @mu_name = mu_name ? mu_name : @deploy.getResourceName(@config["name"]) + if !mu_name.nil? + @mu_name = mu_name + @cloud_id = Id.new(cloud_desc.id) if @cloud_id + else + @mu_name ||= @deploy.getResourceName(@config["name"], max_length: 31) + end + end + + + # Called automatically by {MU::Deploy#createResources} + # @return [String]: The cloud provider's identifier for this GKE instance. + def create + create_update + end + + # Called automatically by {MU::Deploy#createResources} + def groom + create_update + + kube_conf = @deploy.deploy_dir+"/kubeconfig-#{@config['name']}" + + admin_creds = MU::Cloud::Azure.containers(credentials: @config['credentials']).managed_clusters.list_cluster_admin_credentials( + @resource_group, + @mu_name + ) + admin_creds.kubeconfigs.each { |kube| + next if kube.name != "clusterAdmin" + + cfgfile = "" + kube.value.each { |ord| + cfgfile += ord.chr + } + + File.open(kube_conf, "w"){ |k| + k.puts cfgfile + } + } + + if @config['kubernetes_resources'] + MU::Master.applyKubernetesResources( + @config['name'], + @config['kubernetes_resources'], + kubeconfig: kube_conf, + outputdir: @deploy.deploy_dir + ) + end + + MU.log %Q{How to interact with your AKS cluster\nkubectl --kubeconfig "#{kube_conf}" get events --all-namespaces\nkubectl --kubeconfig "#{kube_conf}" get all\nkubectl --kubeconfig "#{kube_conf}" create -f some_k8s_deploy.yml\nkubectl --kubeconfig "#{kube_conf}" get nodes}, MU::SUMMARY + + end + + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) + found = {} + + # Azure resources are namedspaced by resource group. If we weren't + # told one, we may have to search all the ones we can see. + resource_groups = if args[:resource_group] + [args[:resource_group]] + elsif args[:cloud_id] and args[:cloud_id].is_a?(MU::Cloud::Azure::Id) + [args[:cloud_id].resource_group] + else + MU::Cloud::Azure.resources(credentials: args[:credentials]).resource_groups.list.map { |rg| rg.name } + end + + if args[:cloud_id] + id_str = args[:cloud_id].is_a?(MU::Cloud::Azure::Id) ? args[:cloud_id].name : args[:cloud_id] + resource_groups.each { |rg| + resp = MU::Cloud::Azure.containers(credentials: args[:credentials]).managed_clusters.get(rg, id_str) + found[Id.new(resp.id)] = resp if resp + } + else + if args[:resource_group] + MU::Cloud::Azure.containers(credentials: args[:credentials]).managed_clusters.list_by_resource_group(args[:resource_group]).each { |cluster| + found[Id.new(cluster.id)] = cluster + } + else + MU::Cloud::Azure.containers(credentials: args[:credentials]).managed_clusters.list.each { |cluster| + found[Id.new(cluster.id)] = cluster + } + end + end + + found + end + + # Register a description of this cluster instance with this deployment's metadata. + def notify + base = {} + base = MU.structToHash(cloud_desc) + base["cloud_id"] = @cloud_id.name + base.merge!(@config.to_h) + base + end + + # Does this resource type exist as a global (cloud-wide) artifact, or + # is it localized to a region/zone? + # @return [Boolean] + def self.isGlobal? + false + end + + # Denote whether this resource implementation is experiment, ready for + # testing, or ready for production use. + def self.quality + MU::Cloud::BETA + end + + # Stub method. Azure resources are cleaned up by removing the parent + # resource group. + # @return [void] + def self.cleanup(**args) + end + + # Cloud-specific configuration properties. + # @param config [MU::Config]: The calling MU::Config object + # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource + def self.schema(config) + toplevel_required = [] + schema = { + "flavor" => { + "enum" => ["Kubernetes", "OpenShift", "Swarm", "DC/OS"], + "description" => "The Azure container platform to deploy. Currently only +Kubernetes+ is supported.", + "default" => "Kubernetes" + }, + "platform" => { + "description" => "The OS platform to deploy for workers and containers.", + "default" => "Linux", + "enum" => ["Linux", "Windows"] + }, + "max_pods" => { + "type" => "integer", + "description" => "Maximum number of pods allowed on this cluster", + "default" => 30 + }, + "kubernetes" => { + "default" => { "version" => "1.12.8" } + }, + "dns_prefix" => { + "type" => "string", + "description" => "DNS name prefix to use with the hosted Kubernetes API server FQDN. Will default to the global +appname+ value if not specified." + }, + "disk_size_gb" => { + "type" => "integer", + "description" => "Size of the disk attached to each worker, specified in GB. The smallest allowed disk size is 30, the largest 1024.", + "default" => 100 + }, + } + [toplevel_required, schema] + end + + # Cloud-specific pre-processing of {MU::Config::BasketofKittens::container_clusters}, bare and unvalidated. + # @param cluster [Hash]: The resource to process and validate + # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member + # @return [Boolean]: True if validation succeeded, False otherwise + def self.validateConfig(cluster, configurator) + ok = true +# XXX validate k8s versions (master and node) +# XXX validate image types +# MU::Cloud::Azure.container.get_project_zone_serverconfig(@config["project"], @config['availability_zone']) + cluster["dns_prefix"] ||= $myAppName # XXX woof globals wtf + cluster['region'] ||= MU::Cloud::Azure.myRegion(cluster['credentials']) + + if cluster["disk_size_gb"] < 30 or cluster["disk_size_gb"] > 1024 + MU.log "Azure ContainerCluster disk_size_gb must be between 30 and 1024.", MU::ERR + ok = false + end + + if cluster['min_size'] and cluster['instance_count'] < cluster['min_size'] + cluster['instance_count'] = cluster['min_size'] + end + if cluster['max_size'] and cluster['instance_count'] < cluster['max_size'] + cluster['instance_count'] = cluster['max_size'] + end + + cluster['instance_type'] ||= "Standard_DS2_v2" # TODO when Server is implemented, it should have a validateInstanceType method we can use here + + svcacct_desc = { + "name" => cluster["name"]+"user", + "region" => cluster["region"], + "type" => "service", + "cloud" => "Azure", + "create_api_key" => true, + "credentials" => cluster["credentials"], + "roles" => [ + "Azure Kubernetes Service Cluster Admin Role" + ] + } + cluster['dependencies'] ||= [] + cluster['dependencies'] << { + "type" => "user", + "name" => cluster["name"]+"user" + } + + ok = false if !configurator.insertKitten(svcacct_desc, "users") + + ok + end + + private + + def create_update + need_apply = false + + ext_cluster = MU::Cloud::Azure.containers(credentials: @config[:credentials]).managed_clusters.get( + @resource_group, + @mu_name + ) + if ext_cluster + @cloud_id = MU::Cloud::Azure::Id.new(ext_cluster.id) + end + + key_obj = MU::Cloud::Azure.containers(:ContainerServiceSshPublicKey).new + key_obj.key_data = @deploy.ssh_public_key + + ssh_obj = MU::Cloud::Azure.containers(:ContainerServiceSshConfiguration).new + ssh_obj.public_keys = [key_obj] + + os_profile_obj = if !ext_cluster + if @config['platform'] == "Windows" + os_obj = MU::Cloud::Azure.containers(:ContainerServiceWindowsProfile, model_version: "V2019_02_01").new + os_obj.admin_username = "muadmin" + # Azure password constraints are extra-annoying + winpass = MU.generateWindowsPassword(safe_pattern: '!@#$%^&*()', retries: 150) +# TODO store this somewhere the user can get at it + os_obj.admin_password = winpass + os_obj + else + os_obj = MU::Cloud::Azure.containers(:ContainerServiceLinuxProfile).new + os_obj.admin_username = "muadmin" + os_obj.ssh = ssh_obj + os_obj + end + else + # Azure does not support updates to this parameter + @config['platform'] == "Windows" ? ext_cluster.windows_profile : ext_cluster.linux_profile + end + + svc_principal_obj = MU::Cloud::Azure.containers(:ManagedClusterServicePrincipalProfile).new +# XXX this should come from a MU::Cloud::Azure::User object, but right now +# there's no way to get the 'secret' field from a user-assigned identity afaict +# For now, we'll cheat with Mu's system credentials. + creds = MU::Cloud::Azure.credConfig(@config['credentials']) + svc_principal_obj.client_id = creds["client_id"] + svc_principal_obj.secret = creds["client_secret"] + +# svc_acct = @deploy.findLitterMate(type: "user", name: @config['name']+"user") +# raise MuError, "Failed to locate service account #{@config['name']}user" if !svc_acct +# svc_principal_obj.client_id = svc_acct.cloud_desc.client_id +# svc_principal_obj.secret = svc_acct.getSecret + + agent_profiles = if !ext_cluster + profile_obj = MU::Cloud::Azure.containers(:ManagedClusterAgentPoolProfile).new + profile_obj.name = @deploy.getResourceName(@config["name"], max_length: 11).downcase.gsub(/[^0-9a-z]/, "") + if @config['min_size'] and @config['max_size'] + # Special API features need to be enabled for scaling + MU::Cloud::Azure.ensureFeature("Microsoft.ContainerService/WindowsPreview", credentials: @config['credentials']) + MU::Cloud::Azure.ensureFeature("Microsoft.ContainerService/VMSSPreview", credentials: @config['credentials']) + + profile_obj.min_count = @config['min_size'] + profile_obj.max_count = @config['max_size'] + profile_obj.enable_auto_scaling = true + profile_obj.type = MU::Cloud::Azure.containers(:AgentPoolType)::VirtualMachineScaleSets +# XXX if you actually try to do this: +# BadRequest: Virtual Machine Scale Set agent nodes are not allowed since feature "Microsoft.ContainerService/WindowsPreview" is not enabled. + end + profile_obj.count = @config['instance_count'] + profile_obj.vm_size = @config['instance_type'] + profile_obj.max_pods = @config['max_pods'] + profile_obj.os_type = @config['platform'] + profile_obj.os_disk_size_gb = @config['disk_size_gb'] +# XXX correlate this with the one(s) we configured in @config['vpc'] +# profile_obj.vnet_subnet_id = @vpc.subnets.first.cloud_desc.id # XXX has to have its own subnet for k8s apparently + [profile_obj] + else + # Azure does not support adding/removing agent profiles to a live + # cluster, but it does support changing some values on an existing + # one. + profile_obj = ext_cluster.agent_pool_profiles.first + + nochange_map = { + "disk_size_gb" => :os_disk_size_gb, + "instance_type" => :vm_size, + "platform" => :os_type, + "max_pods" => :max_pods, + } + + tried_to_change =[] + nochange_map.each_pair { |cfg, attribute| + if @config.has_key?(cfg) and + @config[cfg] != profile_obj.send(attribute) + tried_to_change << cfg + end + } + if @config['min_size'] and @config['max_size'] and + !profile_obj.enable_auto_scaling + tried_to_change << "enable_auto_scaling" + end + if tried_to_change.size > 0 + MU.log "Changes specified to one or more immutable AKS Agent Pool parameters in cluster #{@mu_name}, ignoring.", MU::NOTICE, details: tried_to_change + end + + if @config['min_size'] and @config['max_size'] and + profile_obj.enable_auto_scaling and + ( + profile_obj.min_count != @config['min_size'] or + profile_obj.max_count != @config['max_size'] + ) + profile_obj.min_count = @config['min_size'] + profile_obj.max_count = @config['max_size'] + need_apply = true + end + + if profile_obj.count != @config['instance_count'] + profile_obj.count = @config['instance_count'] + need_apply = true + end + + [profile_obj] + end + + cluster_obj = MU::Cloud::Azure.containers(:ManagedCluster).new + + if ext_cluster + cluster_obj.dns_prefix = ext_cluster.dns_prefix + cluster_obj.location = ext_cluster.location + else + # Azure does not support updates to these parameters + cluster_obj.dns_prefix = @config['dns_prefix'] + cluster_obj.location = @config['region'] + end + + cluster_obj.tags = @tags + + cluster_obj.service_principal_profile = svc_principal_obj + if @config['platform'] == "Windows" + cluster_obj.windows_profile = os_profile_obj + else + cluster_obj.linux_profile = os_profile_obj + end +# cluster_obj.api_server_authorized_ipranges = [MU.mu_public_ip+"/32", MU.my_private_ip+"/32"] # XXX only allowed with Microsoft.ContainerService/APIServerSecurityPreview enabled + cluster_obj.agent_pool_profiles = agent_profiles + + if @config['flavor'] == "Kubernetes" + cluster_obj.kubernetes_version = @config['kubernetes']['version'].to_s + if ext_cluster and @config['kubernetes']['version'] != ext_cluster.kubernetes_version + need_apply = true + end + end + +# XXX it may be possible to create a new AgentPool and fall forward into it? +# API behavior suggests otherwise. Project for later. +# pool_obj = MU::Cloud::Azure.containers(:AgentPool).new +# pool_obj.count = @config['instance_count'] +# pool_obj.vm_size = "Standard_DS2_v2" + + if !ext_cluster +pp cluster_obj + MU.log "Creating AKS cluster #{@mu_name}", details: cluster_obj + need_apply = true + elsif need_apply + MU.log "Updating AKS cluster #{@mu_name}", MU::NOTICE, details: cluster_obj + end + + if need_apply + resp = MU::Cloud::Azure.containers(credentials: @config['credentials']).managed_clusters.create_or_update( + @resource_group, + @mu_name, + cluster_obj + ) + + @cloud_id = Id.new(resp.id) + end + + end + + end #class + end #class + end +end #module diff --git a/modules/mu/clouds/azure/firewall_rule.rb b/modules/mu/clouds/azure/firewall_rule.rb new file mode 100644 index 000000000..d53bdedc6 --- /dev/null +++ b/modules/mu/clouds/azure/firewall_rule.rb @@ -0,0 +1,500 @@ +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module MU + + class Cloud + class Azure + # A firewall ruleset as configured in {MU::Config::BasketofKittens::firewall_rules} + class FirewallRule < MU::Cloud::FirewallRule + + @admin_sgs = Hash.new + @admin_sg_semaphore = Mutex.new + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + + if !mu_name.nil? + @mu_name = mu_name + else + @mu_name = @deploy.getResourceName(@config['name'], max_length: 61) + end + + end + + attr_reader :rulesets + + # Called by {MU::Deploy#createResources} + def create + create_update + end + + # Called by {MU::Deploy#createResources} + def groom + create_update + + oldrules = {} + newrules = {} + cloud_desc.security_rules.each { |rule| + if rule.description and rule.description.match(/^#{Regexp.quote(@mu_name)} \d+:/) + oldrules[rule.name] = rule + end + } + used_priorities = oldrules.values.map { |r| r.priority } + + newrules_semaphore = Mutex.new + num_rules = 0 + + rulethreads = [] + return if !@config['rules'] + @config['rules'].each { |rule_cfg| + num_rules += 1 + rulethreads << Thread.new(rule_cfg, num_rules) { |rule, num| + was_new, desc = addRule( + rule["hosts"], + proto: rule["proto"], + port: rule["port"], + egress: rule["egress"], + port_range: rule["port_range"], + sgs: rule["sgs"], + lbs: rule["lbs"], + deny: rule["deny"], + weight: rule["weight"], + oldrules: oldrules, + num: num + ) + + newrules_semaphore.synchronize { + newrules[desc.name] = desc + if !was_new + oldrules[desc.name] = desc + end + } + + } # rulethreads + } + + rulethreads.each { |t| + t.join + } + + # Purge old rules that we own (according to the description) but + # which are not part of our current configuration. + (oldrules.keys - newrules.keys).each { |oldrule| + MU.log "Dropping unused rule #{oldrule} from #{@mu_name}", MU::NOTICE + MU::Cloud::Azure.network(credentials: @config['credentials']).security_rules.delete(@resource_group, @mu_name, oldrule) + } + + end + + # Log metadata about this ruleset to the currently running deployment + def notify + MU.structToHash(cloud_desc) + end + + # Insert a rule into an existing security group. + # + # @param hosts [Array]: An array of CIDR network addresses to which this rule will apply. + # @param proto [String]: One of "tcp," "udp," or "icmp" + # @param port [Integer]: A port number. Only valid with udp or tcp. + # @param egress [Boolean]: Whether this is an egress ruleset, instead of ingress. + # @param port_range [String]: A port range descriptor (e.g. 0-65535). Only valid with udp or tcp. + # @return [Array] + def addRule(hosts, proto: "tcp", port: nil, egress: false, port_range: "0-65535", sgs: [], lbs: [], deny: false, weight: nil, oldrules: nil, num: 0, description: "") + if !oldrules + oldrules = {} + cloud_desc(use_cache: false).security_rules.each { |rule| + if rule.description and rule.description.match(/^#{Regexp.quote(@mu_name)} \d+:/) + oldrules[rule.name] = rule + end + } + end + used_priorities = oldrules.values.map { |r| r.priority } + + rule_obj = MU::Cloud::Azure.network(:SecurityRule).new + resolved_sgs = [] +# XXX these are *Application* Security Groups, which are a different kind of +# artifact. They take no parameters. Are they essentially a stub that can be +# attached to certain artifacts to allow them to be referenced here? +# http://54.175.86.194/docs/azure/Azure/Network/Mgmt/V2019_02_01/ApplicationSecurityGroups.html#create_or_update-instance_method + if sgs + sgs.each { |sg| +# look up cloud id for... whatever these are + } + end + + resolved_lbs = [] + if lbs + lbs.each { |lb| +# TODO awaiting LoadBalancer implementation + } + end + + if egress + rule_obj.direction = MU::Cloud::Azure.network(:SecurityRuleDirection)::Outbound + if hosts and !hosts.empty? + rule_obj.source_address_prefix = "*" + if hosts == ["*"] + rule_obj.destination_address_prefix = "*" + else + rule_obj.destination_address_prefixes = hosts + end + end + if !resolved_sgs.empty? + rule_obj.destination_application_security_groups = resolved_sgs + end + if !rule_obj.destination_application_security_groups and + !rule_obj.destination_address_prefix and + !rule_obj.destination_address_prefixes + rule_obj.source_address_prefix = "*" + rule_obj.destination_address_prefix = "*" + end + else + rule_obj.direction = MU::Cloud::Azure.network(:SecurityRuleDirection)::Inbound + if hosts and !hosts.empty? + if hosts == ["*"] + rule_obj.source_address_prefix = "*" + else + rule_obj.source_address_prefixes = hosts + end + rule_obj.destination_address_prefix = "*" + end + if !resolved_sgs.empty? + rule_obj.source_application_security_groups = resolved_sgs + end + if !rule_obj.source_application_security_groups and + !rule_obj.source_address_prefix and + !rule_obj.source_address_prefixes + # should probably only do this if a port or port_range is named + rule_obj.source_address_prefix = "*" + rule_obj.destination_address_prefix = "*" + end + end + + rname_port = "port-" + if port and port.to_s != "-1" + rule_obj.destination_port_range = port.to_s + rname_port += port.to_s + elsif port_range and port_range != "-1" + rule_obj.destination_port_range = port_range + rname_port += port_range + else + rule_obj.destination_port_range = "*" + rname_port += "all" + end + + # We don't bother supporting restrictions on originating ports, + # because practically nobody does that. + rule_obj.source_port_range = "*" + + rule_obj.protocol = MU::Cloud::Azure.network(:SecurityRuleProtocol).const_get(proto.capitalize) + rname_proto = "proto-"+ (proto == "asterisk" ? "all" : proto) + + if deny + rule_obj.access = MU::Cloud::Azure.network(:SecurityRuleAccess)::Deny + else + rule_obj.access = MU::Cloud::Azure.network(:SecurityRuleAccess)::Allow + end + + rname = rule_obj.access.downcase+"-"+rule_obj.direction.downcase+"-"+rname_proto+"-"+rname_port+"-"+num.to_s + + if weight + rule_obj.priority = weight + elsif oldrules[rname] + rule_obj.priority = oldrules[rname].priority + else + default_priority = 999 + begin + default_priority += 1 + num + rule_obj.priority = default_priority + end while used_priorities.include?(default_priority) + end + used_priorities << rule_obj.priority + + rule_obj.description = "#{@mu_name} #{num.to_s}: #{rname}" + + # Now compare this to existing rules, and see if we need to update + # anything. + need_update = false + if oldrules[rname] + rule_obj.instance_variables.each { |var| + oldval = oldrules[rname].instance_variable_get(var) + newval = rule_obj.instance_variable_get(var) + need_update = true if oldval != newval + } + + [:@destination_address_prefix, :@destination_address_prefixes, + :@destination_application_security_groups, + :@destination_address_prefix, + :@destination_address_prefixes, + :@destination_application_security_groups].each { |var| + next if !oldrules[rname].instance_variables.include?(var) + oldval = oldrules[rname].instance_variable_get(var) + newval = rule_obj.instance_variable_get(var) + if newval.nil? and !oldval.nil? and !oldval.empty? + need_update = true + end + } + else + need_update = true + end + + if need_update + if oldrules[rname] + MU.log "Updating rule #{rname} in #{@mu_name}", MU::NOTICE, details: rule_obj + else + MU.log "Creating rule #{rname} in #{@mu_name}", details: rule_obj + end + + resp = MU::Cloud::Azure.network(credentials: @config['credentials']).security_rules.create_or_update(@resource_group, @mu_name, rname, rule_obj) + return [!oldrules[rname].nil?, resp] + else + return [false, oldrules[rname]] + end + + end + + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) + found = {} + + # Azure resources are namedspaced by resource group. If we weren't + # told one, we may have to search all the ones we can see. + resource_groups = if args[:resource_group] + [args[:resource_group]] + elsif args[:cloud_id] and args[:cloud_id].is_a?(MU::Cloud::Azure::Id) + [args[:cloud_id].resource_group] + else + MU::Cloud::Azure.resources(credentials: args[:credentials]).resource_groups.list.map { |rg| rg.name } + end + + if args[:cloud_id] + id_str = args[:cloud_id].is_a?(MU::Cloud::Azure::Id) ? args[:cloud_id].name : args[:cloud_id] + resource_groups.each { |rg| + begin + resp = MU::Cloud::Azure.network(credentials: args[:credentials]).network_security_groups.get(rg, id_str) + next if resp.nil? + found[Id.new(resp.id)] = resp + rescue MU::Cloud::Azure::APIError => e + # this is fine, we're doing a blind search after all + end + } + else + if args[:resource_group] + MU::Cloud::Azure.network(credentials: args[:credentials]).network_security_groups.list(args[:resource_group]).each { |net| + found[Id.new(net.id)] = net + } + else + MU::Cloud::Azure.network(credentials: args[:credentials]).network_security_groups.list_all.each { |net| + found[Id.new(net.id)] = net + } + end + end + + found + end + + # Does this resource type exist as a global (cloud-wide) artifact, or + # is it localized to a region/zone? + # @return [Boolean] + def self.isGlobal? + false + end + + # Denote whether this resource implementation is experiment, ready for + # testing, or ready for production use. + def self.quality + MU::Cloud::BETA + end + + # Stub method. Azure resources are cleaned up by removing the parent + # resource group. + # @return [void] + def self.cleanup(**args) + end + + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + def toKitten(rootparent: nil, billing: nil) + bok = {} + + bok + end + + # Cloud-specific configuration properties. + # @param config [MU::Config]: The calling MU::Config object + # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource + def self.schema(config = nil) + toplevel_required = [] + hosts_schema = MU::Config::CIDR_PRIMITIVE + hosts_schema["pattern"] = "^(\\d+\\.\\d+\\.\\d+\\.\\d+\/[0-9]{1,2}|\\*)$" + schema = { + "rules" => { + "items" => { + "properties" => { + "hosts" => { + "type" => "array", + "items" => hosts_schema + }, + "weight" => { + "type" => "integer", + "description" => "Explicitly set a priority for this firewall rule, between 100 and 2096, with lower numbered priority rules having greater precedence." + }, + "deny" => { + "type" => "boolean", + "default" => false, + "description" => "Set this rule to +DENY+ traffic instead of +ALLOW+" + }, + "proto" => { + "description" => "The protocol to allow with this rule. The +standard+ keyword will expand to a series of identical rules covering +tcp+ and +udp; the +all+ keyword will allow all supported protocols. Currently only +tcp+ and +udp+ are supported by Azure, so the end result of these two keywords is identical.", + "enum" => ["all", "standard", "tcp", "udp"], + "default" => "standard" + }, +# "source_tags" => { +# "type" => "array", +# "description" => "VMs with these tags, from which traffic will be allowed", +# "items" => { +# "type" => "string" +# } +# }, +# "source_service_accounts" => { +# "type" => "array", +# "description" => "Resources using these service accounts, from which traffic will be allowed", +# "items" => { +# "type" => "string" +# } +# }, +# "target_tags" => { +# "type" => "array", +# "description" => "VMs with these tags, to which traffic will be allowed", +# "items" => { +# "type" => "string" +# } +# }, +# "target_service_accounts" => { +# "type" => "array", +# "description" => "Resources using these service accounts, to which traffic will be allowed", +# "items" => { +# "type" => "string" +# } +# } + } + } + }, + } + [toplevel_required, schema] + end + + # Cloud-specific pre-processing of {MU::Config::BasketofKittens::firewall_rules}, bare and unvalidated. + # @param acl [Hash]: The resource to process and validate + # @param config [MU::Config]: The overall deployment config of which this resource is a member + # @return [Boolean]: True if validation succeeded, False otherwise + def self.validateConfig(acl, config) + ok = true + acl['region'] ||= MU::Cloud::Azure.myRegion(acl['credentials']) + + append = [] + delete = [] + acl['rules'] ||= [] + acl['rules'].concat(config.adminFirewallRuleset(cloud: "Azure", region: acl['region'], rules_only: true)) + acl['rules'].each { |r| + if r["weight"] and (r["weight"] < 100 or r["weight"] > 4096) + MU.log "FirewallRule #{acl['name']} weight must be between 100 and 4096", MU::ERR + ok = false + end + if r["hosts"] + r["hosts"].each { |cidr| + r["hosts"] << "*" if cidr == "0.0.0.0/0" + } + r["hosts"].delete("0.0.0.0/0") + end + + if (!r['hosts'] or r['hosts'].empty?) and + (!r['lbs'] or r['lbs'].empty?) and + (!r['sgs'] or r['sgs'].empty?) + r["hosts"] = ["*"] + MU.log "FirewallRule #{acl['name']} did not specify any hosts, sgs or lbs, defaulting this rule to allow 0.0.0.0/0", MU::NOTICE + end + + + if r['proto'] == "standard" + ["tcp", "udp"].each { |p| + newrule = r.dup + newrule['proto'] = p + append << newrule + } + delete << r + elsif r['proto'] == "all" or !r['proto'] + r['proto'] = "asterisk" # legit, the name of the constant + end + } + delete.each { |r| + acl['rules'].delete(r) + } + acl['rules'].concat(append) + + ok + end + + private + + def create_update + + fw_obj = MU::Cloud::Azure.network(:NetworkSecurityGroup).new + fw_obj.location = @config['region'] + fw_obj.tags = @tags + + need_apply = false + ext_ruleset = MU::Cloud::Azure.network(credentials: @config['credentials']).network_security_groups.get( + @resource_group, + @mu_name + ) + if ext_ruleset + @cloud_id = MU::Cloud::Azure::Id.new(ext_ruleset.id) + end + + if !ext_ruleset + MU.log "Creating Network Security Group #{@mu_name} in #{@config['region']}", details: fw_obj + need_apply = true + elsif ext_ruleset.location != fw_obj.location or + ext_ruleset.tags != fw_obj.tags + MU.log "Updating Network Security Group #{@mu_name} in #{@config['region']}", MU::NOTICE, details: fw_obj + need_apply = true + end + + if need_apply + resp = MU::Cloud::Azure.network(credentials: @config['credentials']).network_security_groups.create_or_update( + @resource_group, + @mu_name, + fw_obj + ) + + @cloud_id = MU::Cloud::Azure::Id.new(resp.id) + end + end + + end #class + end #class + end +end #module diff --git a/modules/mu/clouds/azure/habitat.rb b/modules/mu/clouds/azure/habitat.rb new file mode 100644 index 000000000..b9f30d7ee --- /dev/null +++ b/modules/mu/clouds/azure/habitat.rb @@ -0,0 +1,167 @@ +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module MU + class Cloud + class Azure + # Creates an Azure directory as configured in {MU::Config::BasketofKittens::habitats} + class Habitat < MU::Cloud::Habitat + + # Placeholder method, just here to see which bits of the subscription + # API actually work. Delete this once we actually have enough + # functionality for a real implementation. + def self.testcalls + +#pp MU::Cloud::Azure::Habitat.find + + pp MU::Cloud::Azure.billing.enrollment_accounts.list + +# pp MU::Cloud::Azure.subfactory.api.class.name + +# pp MU::Cloud::Azure.subfactory.subscription_factory.create_subscription_in_enrollment_account # this should barf + end + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + + cloud_desc if @cloud_id # XXX why don't I have this on regroom? + if !@cloud_id and cloud_desc and cloud_desc.project_id + @cloud_id = cloud_desc.project_id + end + + if !mu_name.nil? + @mu_name = mu_name + elsif @config['scrub_mu_isms'] + @mu_name = @config['name'] + else + @mu_name = @deploy.getResourceName(@config['name']) + end + end + + # Called automatically by {MU::Deploy#createResources} + def create + end + + # Called automatically by {MU::Deploy#createResources} + def groom + end + + # Return the cloud descriptor for the Habitat + def cloud_desc + @cached_cloud_desc ||= MU::Cloud::Azure::Habitat.find(cloud_id: @cloud_id).values.first +# @habitat_id ||= @cached_cloud_desc.parent.id if @cached_cloud_desc + @cached_cloud_desc + end + + # Return the metadata for this project's configuration + # @return [Hash] + def notify +# MU.structToHash(MU::Cloud::Google.resource_manager(credentials: @config['credentials']).get_project(@cloud_id)) + {} + end + + # Does this resource type exist as a global (cloud-wide) artifact, or + # is it localized to a region/zone? + # @return [Boolean] + def self.isGlobal? + true + end + + # Denote whether this resource implementation is experiment, ready for + # testing, or ready for production use. + def self.quality + MU::Cloud::ALPHA + end + + # Check whether is in the +ACTIVE+ state and has billing enabled. + # @param project_id [String] + # @return [Boolean] + def self.isLive?(project_id, credentials = nil) + true + end + + # Stub method. Azure resources are cleaned up by removing the parent + # resource group. + # @return [void] + def self.cleanup(**args) + end + + @@list_projects_cache = nil + + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) +#MU.log "habitat.find called by #{caller[0]}", MU::WARN, details: args + found = {} + + args[:cloud_id] ||= args[:project] +# XXX we probably want to cache this +# XXX but why are we being called over and over? + + if args[:cloud_id] + found[args[:cloud_id]] = MU::Cloud::Azure.subs.subscriptions.get(args[:cloud_id]) + else + MU::Cloud::Azure.subs.subscriptions.list.each { |sub| + found[sub.subscription_id] = sub + } + end + + found + end + + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + def toKitten(rootparent: nil, billing: nil) + bok = { + "cloud" => "Azure", + "credentials" => @config['credentials'] + } + + bok + end + + # Cloud-specific configuration properties. + # @param config [MU::Config]: The calling MU::Config object + # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource + def self.schema(config) + toplevel_required = [] + schema = { + } + [toplevel_required, schema] + end + + # Cloud-specific pre-processing of {MU::Config::BasketofKittens::habitats}, bare and unvalidated. + # @param habitat [Hash]: The resource to process and validate + # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member + # @return [Boolean]: True if validation succeeded, False otherwise + def self.validateConfig(habitat, configurator) + ok = true + habitat['region'] ||= MU::Cloud::Azure.myRegion(habitat['credentials']) + + ok + end + + end + end + end +end diff --git a/modules/mu/clouds/azure/loadbalancer.rb b/modules/mu/clouds/azure/loadbalancer.rb new file mode 100644 index 000000000..8184bffde --- /dev/null +++ b/modules/mu/clouds/azure/loadbalancer.rb @@ -0,0 +1,205 @@ +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module MU + class Cloud + class Azure + # A load balancer as configured in {MU::Config::BasketofKittens::loadbalancers} + class LoadBalancer < MU::Cloud::LoadBalancer + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + @mu_name ||= @deploy.getResourceName(@config["name"]) + end + + # Called automatically by {MU::Deploy#createResources} + def create + create_update + end + + # Called automatically by {MU::Deploy#createResources} + def groom + create_update + + if cloud_desc.tags != @tags + tags_obj = MU::Cloud::Azure.network(:TagsObject).new + tags_obj.tags = @tags + MU.log "Updating tags on LoadBalancer #{@mu_name}", MU::NOTICE, details: @tags + MU::Cloud::Azure.network(credentials: @config['credentials']).load_balancers.update_tags(@resource_group, @mu_name, tags_obj) + end + end + + # Return the metadata for this LoadBalancer + # @return [Hash] + def notify + end + + # Register a Server node with an existing LoadBalancer. + # + # @param instance_id [String] A node to register. + # @param targetgroups [Array] The target group(s) of which this node should be made a member. Not applicable to classic LoadBalancers. If not supplied, the node will be registered to all available target groups on this LoadBalancer. + def registerNode(instance_id, targetgroups: nil) + end + + # Does this resource type exist as a global (cloud-wide) artifact, or + # is it localized to a region/zone? + # @return [Boolean] + def self.isGlobal? + false + end + + # Denote whether this resource implementation is experiment, ready for + # testing, or ready for production use. + def self.quality + MU::Cloud::ALPHA + end + + # Stub method. Azure resources are cleaned up by removing the parent + # resource group. + # @return [void] + def self.cleanup(**args) + end + + # Cloud-specific configuration properties. + # @param config [MU::Config]: The calling MU::Config object + # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource + def self.schema(config) + toplevel_required = [] + schema = { +# "named_ports" => { +# "type" => "array", +# "items" => { +# "type" => "object", +# "required" => ["name", "port"], +# "additionalProperties" => false, +# "description" => "A named network port for a Azure instance group, used for health checks and forwarding targets.", +# "properties" => { +# "name" => { +# "type" => "string" +# }, +# "port" => { +# "type" => "integer" +# } +# } +# } +# } + } + [toplevel_required, schema] + end + + # Cloud-specific pre-processing of {MU::Config::BasketofKittens::loadbalancers}, bare and unvalidated. + # @param lb [Hash]: The resource to process and validate + # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member + # @return [Boolean]: True if validation succeeded, False otherwise + def self.validateConfig(lb, configurator) + ok = true + lb['region'] ||= MU::Cloud::Azure.myRegion(lb['credentials']) + + ok + end + + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) + found = {} + + # Azure resources are namedspaced by resource group. If we weren't + # told one, we may have to search all the ones we can see. + resource_groups = if args[:resource_group] + [args[:resource_group]] + elsif args[:cloud_id] and args[:cloud_id].is_a?(MU::Cloud::Azure::Id) + [args[:cloud_id].resource_group] + else + MU::Cloud::Azure.resources(credentials: args[:credentials]).resource_groups.list.map { |rg| rg.name } + end + + if args[:cloud_id] + id_str = args[:cloud_id].is_a?(MU::Cloud::Azure::Id) ? args[:cloud_id].name : args[:cloud_id] + resource_groups.each { |rg| + resp = MU::Cloud::Azure.network(credentials: args[:credentials]).load_balancers.get(rg, id_str) + found[Id.new(resp.id)] = resp if resp + } + else + if args[:resource_group] + MU::Cloud::Azure.network(credentials: args[:credentials]).load_balancers.list(args[:resource_group]).each { |lb| + found[Id.new(lb.id)] = lb + } + else + MU::Cloud::Azure.network(credentials: args[:credentials]).load_balancers.list_all.each { |net| + found[Id.new(lb.id)] = lb + } + end + end + + found + end + + private + + def create_update + @config['region'] ||= MU::Cloud::Azure.myRegion(@config['credentials']) + +# XXX expose that second argument to BoK language to use a pre-existing resource + ip_obj = MU::Cloud::Azure.fetchPublicIP(@resource_group, @mu_name, credentials: @config['credentials'], region: @config['region'], tags: @tags) + +# XXX can have multiples of these + front_obj = MU::Cloud::Azure.network(:FrontendIPConfiguration).new + front_obj.name = @mu_name + front_obj.public_ipaddress = ip_obj + front_obj.private_ipallocation_method = "Dynamic" + + lb_obj = MU::Cloud::Azure.network(:LoadBalancer).new + lb_obj.frontend_ipconfigurations = [front_obj] + lb_obj.location = @config['region'] + lb_obj.tags = @tags + + + need_apply = false + ext_lb = MU::Cloud::Azure.network(credentials: @config['credentials']).load_balancers.get( + @resource_group, + @mu_name + ) + if ext_lb + pp ext_lb + @cloud_id = MU::Cloud::Azure::Id.new(ext_lb.id) + end +#MU.log "WHAT I GOT", MU::NOTICE, details: ext_lb +#MU.log "WHAT I NEED", MU::NOTICE, details: @config + + if !ext_lb + MU.log "Creating Load Balancer #{@mu_name} in #{@config['region']}", details: lb_obj + need_apply = true + elsif ext_lb.frontend_ipconfigurations != lb_obj.frontend_ipconfigurations + MU.log "Updating Network Security Group #{@mu_name} in #{@config['region']}", MU::NOTICE, details: lb_obj + need_apply = true + end + + if need_apply + resp = MU::Cloud::Azure.network(credentials: @config['credentials']).load_balancers.create_or_update(@resource_group, @mu_name, lb_obj) + @cloud_id = Id.new(resp.id) + end + end + + end + end + end +end diff --git a/modules/mu/clouds/azure/role.rb b/modules/mu/clouds/azure/role.rb new file mode 100644 index 000000000..25d2943a3 --- /dev/null +++ b/modules/mu/clouds/azure/role.rb @@ -0,0 +1,211 @@ +# Copyright:: Copyright (c) 2018 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module MU + class Cloud + class Azure + # A user as configured in {MU::Config::BasketofKittens::roles} + class Role < MU::Cloud::Role + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + if !mu_name.nil? + @mu_name = mu_name + @cloud_id = Id.new(cloud_desc.id) if @cloud_id + else + @mu_name ||= @deploy.getResourceName(@config["name"], max_length: 31) + end + + end + + # Called automatically by {MU::Deploy#createResources} + def create + end + + # Called automatically by {MU::Deploy#createResources} + def groom + end + + # Return the metadata for this user configuration + # @return [Hash] + def notify + description = MU.structToHash(cloud_desc) + if description + description.delete(:etag) + return description + end + { + } + end + + # Does this resource type exist as a global (cloud-wide) artifact, or + # is it localized to a region/zone? + # @return [Boolean] + def self.isGlobal? + false + end + + # Denote whether this resource implementation is experiment, ready for + # testing, or ready for production use. + def self.quality + MU::Cloud::ALPHA + end + + # Assign this role object to a given principal (create a RoleAssignment) + # @param principal [MU::Cloud::Azure::Id] + def assignTo(principal) + MU::Cloud::Azure::Role.assignTo(principal_id, role_id: @cloud_id) + end + + # Assign a role to a particular principal (create a RoleAssignment). We + # support multiple ways of referring to a role + # @param principal [MU::Cloud::Azure::Id] + def self.assignTo(principal, role_name: nil, role_id: nil, credentials: nil) +# XXX subscription might need extraction + if !role_name and !role_id + raise MuError, "Role.assignTo requries one of role_name, role_id, or permissions in order to look up roles for association" + + end + + existing = MU::Cloud::Azure.authorization(credentials: credentials).role_assignments.list() + + roles = MU::Cloud::Azure::Role.find(cloud_id: role_id, role_name: role_name, credentials: credentials) + role = roles.values.first # XXX handle failures and multiples + + assign_obj = MU::Cloud::Azure.authorization(:RoleAssignmentCreateParameters, model_version: "V2018_09_01_preview").new + assign_obj.principal_id = principal + assign_obj.principal_type = "ServicePrincipal" + assign_obj.role_definition_id = role.id + + # TODO this should defintiely be configurable, and for most Mu + # deploy resources will be scoped to the resource group level + scope = "/subscriptions/"+MU::Cloud::Azure.default_subscription(credentials) + + role_name = begin + role.role_name + rescue NoMethodError + role.properties.role_name + end + + used_ids = [] + existing.each { |ext_assignment| + used_ids << ext_assignment.name + if ext_assignment.role_definition_id == role.id and + ext_assignment.scope == scope and + ext_assignment.principal_id == principal + return + end + } + + guid = nil + begin + guid = MU::Cloud::Azure.genGUID + end while used_ids.include?(guid) + + MU.log "Assigning role '#{role_name}' to principal #{principal}", details: assign_obj + MU::Cloud::Azure.authorization(credentials: credentials).role_assignments.create( + scope, + guid, + assign_obj + ) + end + + @@role_list_cache = {} + @@role_list_semaphore = Mutex.new + + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) + found = {} + + sub_id = MU::Cloud::Azure.default_subscription(args[:credentials]) + scope = "/subscriptions/"+sub_id + + if args[:cloud_id] + id_str = args[:cloud_id].is_a?(MU::Cloud::Azure::Id) ? args[:cloud_id].name : args[:cloud_id] + begin + resp = MU::Cloud::Azure.authorization(credentials: args[:credentials]).role_definitions.get(scope, id_str) + found[Id.new(resp.id)] = resp + rescue MsRestAzure::AzureOperationError => e + # this is fine, we're doing a blind search after all + end + else + @@role_list_semaphore.synchronize { + if !@@role_list_cache[scope] + @@role_list_cache[scope] = Hash[MU::Cloud::Azure.authorization(credentials: args[:credentials]).role_definitions.list(scope).map { |r| [Id.new(r.id), r] }] + end + } + if args[:role_name] + @@role_list_cache[scope].each_pair { |key, role| + begin + if role.role_name == args[:role_name] + found[Id.new(role.id)] = role + break + end + rescue NoMethodError + if role.properties.role_name == args[:role_name] + found[Id.new(role.id)] = role + break + end + end + } + else + found = @@role_list_cache[scope].dup + end + end + + found + end + + # Stub method. Azure resources are cleaned up by removing the parent + # resource group. + # @return [void] + def self.cleanup(**args) + end + + # Cloud-specific configuration properties. + # @param config [MU::Config]: The calling MU::Config object + # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource + def self.schema(config) + toplevel_required = [] + schema = { + } + [toplevel_required, schema] + end + + # Cloud-specific pre-processing of {MU::Config::BasketofKittens::roles}, bare and unvalidated. + # @param role [Hash]: The resource to process and validate + # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member + # @return [Boolean]: True if validation succeeded, False otherwise + def self.validateConfig(role, configurator) + ok = true + role['region'] ||= MU::Cloud::Azure.myRegion(role['credentials']) + + ok + end + + private + + end + end + end +end diff --git a/modules/mu/clouds/azure/server.rb b/modules/mu/clouds/azure/server.rb new file mode 100644 index 000000000..01e669c67 --- /dev/null +++ b/modules/mu/clouds/azure/server.rb @@ -0,0 +1,824 @@ +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require 'net/ssh' +require 'net/ssh/multi' +require 'net/ssh/proxy/command' +autoload :OpenStruct, "ostruct" +autoload :Timeout, "timeout" +autoload :ERB, "erb" +autoload :Base64, "base64" +require 'open-uri' + +module MU + class Cloud + class Azure + # A server as configured in {MU::Config::BasketofKittens::servers}. + class Server < MU::Cloud::Server + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + + @userdata = if @config['userdata_script'] + @config['userdata_script'] + elsif @deploy and !@scrub_mu_isms + MU::Cloud.fetchUserdata( + platform: @config["platform"], + cloud: "Azure", + credentials: @config['credentials'], + template_variables: { + "deployKey" => Base64.urlsafe_encode64(@deploy.public_key), + "deploySSHKey" => @deploy.ssh_public_key, + "muID" => MU.deploy_id, + "muUser" => MU.mu_user, + "publicIP" => MU.mu_public_ip, + "adminBucketName" => MU::Cloud::Azure.adminBucketName(@credentials), + "chefVersion" => MU.chefVersion, + "skipApplyUpdates" => @config['skipinitialupdates'], + "windowsAdminName" => @config['windows_admin_username'], + "mommaCatPort" => MU.mommaCatPort, + "resourceName" => @config["name"], + "resourceType" => "server", + "platform" => @config["platform"] + }, + custom_append: @config['userdata_script'] + ) + end + + if !@mu_name + if kitten_cfg.has_key?("basis") + @mu_name = @deploy.getResourceName(@config['name'], need_unique_string: true) + else + @mu_name = @deploy.getResourceName(@config['name']) + end + end + @config['instance_secret'] ||= Password.random(50) + + end + + # Return the date/time a machine image was created. + # @param image_id [String]: URL to a Azure disk image + # @param credentials [String] + # @return [DateTime] + def self.imageTimeStamp(image_id, credentials: nil) + return DateTime.new(0) # Azure doesn't seem to keep this anywhere, boo +# begin +# img = fetchImage(image_id, credentials: credentials) +# return DateTime.new if img.nil? +# return DateTime.parse(img.creation_timestamp) +# rescue ::Azure::Apis::ClientError => e +# end +# +# return DateTime.new + end + + # Called automatically by {MU::Deploy#createResources} + def create + create_update + + if !@config['async_groom'] + sleep 5 + MU::MommaCat.lock(@cloud_id.to_s+"-create") + if !postBoot + MU.log "#{@config['name']} is already being groomed, skipping", MU::NOTICE + else + MU.log "Node creation complete for #{@config['name']}" + end + MU::MommaCat.unlock(@cloud_id.to_s+"-create") + end + + end + + # Return a BoK-style config hash describing a NAT instance. We use this + # to approximate NAT gateway functionality with a plain instance. + # @return [Hash] + def self.genericNAT + return { + "cloud" => "Azure", + "src_dst_check" => false, + "bastion" => true, + "size" => "Standard_B2s", + "run_list" => [ "mu-nat" ], + "groomer" => "Ansible", + "platform" => "centos7", + "associate_public_ip" => true, + "static_ip" => { "assign_ip" => true }, + } + end + + # Ask the Azure API to stop this node + def stop + MU.log "XXX Stopping #{@cloud_id}" + end + + # Ask the Azure API to start this node + def start + MU.log "XXX Starting #{@cloud_id}" + end + + # Ask the Azure API to restart this node + # XXX unimplemented + def reboot(hard = false) + return if @cloud_id.nil? + + end + + # Figure out what's needed to SSH into this server. + # @return [Array]: nat_ssh_key, nat_ssh_user, nat_ssh_host, canonical_ip, ssh_user, ssh_key_name, alternate_names + def getSSHConfig + node, config, deploydata = describe(cloud_id: @cloud_id) +# XXX add some awesome alternate names from metadata and make sure they end +# up in MU::MommaCat's ssh config wangling + ssh_keydir = Etc.getpwuid(Process.uid).dir+"/.ssh" + return nil if @config.nil? or @deploy.nil? + + nat_ssh_key = nat_ssh_user = nat_ssh_host = nil + if !@config["vpc"].nil? and !MU::Cloud::Azure::VPC.haveRouteToInstance?(cloud_desc, region: @config['region'], credentials: @config['credentials']) + + if !@nat.nil? and @nat.mu_name != @mu_name + if @nat.cloud_desc.nil? + MU.log "NAT was missing cloud descriptor when called in #{@mu_name}'s getSSHConfig", MU::ERR + return nil + end + foo, bar, baz, nat_ssh_host, nat_ssh_user, nat_ssh_key = @nat.getSSHConfig + if nat_ssh_user.nil? and !nat_ssh_host.nil? + MU.log "#{@config["name"]} (#{MU.deploy_id}) is configured to use #{@config['vpc']} NAT #{nat_ssh_host}, but username isn't specified. Guessing root.", MU::ERR, details: caller + nat_ssh_user = "root" + end + end + end + + if @config['ssh_user'].nil? + if windows? + @config['ssh_user'] = "Administrator" + else + @config['ssh_user'] = "root" + end + end + + return [nat_ssh_key, nat_ssh_user, nat_ssh_host, canonicalIP, @config['ssh_user'], @deploy.ssh_key_name] + + end + + # Apply tags, bootstrap our configuration management, and other + # administravia for a new instance. + def postBoot(instance_id = nil) + if !instance_id.nil? + @cloud_id ||= instance_id + end + + # Unless we're planning on associating a different IP later, set up a + # DNS entry for this thing and let it sync in the background. We'll + # come back to it later. + if @config['static_ip'].nil? && !@named + MU::MommaCat.nameKitten(self) + @named = true + end + + nat_ssh_key, nat_ssh_user, nat_ssh_host, canonical_ip, ssh_user, ssh_key_name = getSSHConfig + if !nat_ssh_host and !MU::Cloud::Azure::VPC.haveRouteToInstance?(cloud_desc, region: @config['region'], credentials: @config['credentials']) +# XXX check if canonical_ip is in the private ranges +# raise MuError, "#{node} has no NAT host configured, and I have no other route to it" + end + + # See if this node already exists in our config management. If it does, + # we're done. + if @groomer.haveBootstrapped? + MU.log "Node #{@mu_name} has already been bootstrapped, skipping groomer setup.", MU::NOTICE + @groomer.saveDeployData + MU::MommaCat.unlock(@cloud_id.to_s+"-orchestrate") + MU::MommaCat.unlock(@cloud_id.to_s+"-groom") + return true + end + + @groomer.bootstrap + + # Make sure we got our name written everywhere applicable + if !@named + MU::MommaCat.nameKitten(self) + @named = true + end + + MU::MommaCat.unlock(@cloud_id.to_s+"-groom") + MU::MommaCat.unlock(@cloud_id.to_s+"-orchestrate") + return true + end #postBoot + + # @return [Hash]: The cloud provider's complete descriptions of matching instances + def self.find(**args) + found = {} + # told one, we may have to search all the ones we can see. + resource_groups = if args[:resource_group] + [args[:resource_group]] + elsif args[:cloud_id] and args[:cloud_id].is_a?(MU::Cloud::Azure::Id) + [args[:cloud_id].resource_group] + else + MU::Cloud::Azure.resources(credentials: args[:credentials]).resource_groups.list.map { |rg| rg.name } + end + + if args[:cloud_id] + id_str = args[:cloud_id].is_a?(MU::Cloud::Azure::Id) ? args[:cloud_id].name : args[:cloud_id] + resource_groups.each { |rg| + begin + resp = MU::Cloud::Azure.compute(credentials: args[:credentials]).virtual_machines.get(rg, id_str) + next if resp.nil? + found[Id.new(resp.id)] = resp + rescue MU::Cloud::Azure::APIError => e + # this is fine, we're doing a blind search after all + end + } + else + if args[:resource_group] + MU::Cloud::Azure.compute(credentials: args[:credentials]).virtual_machines.list(args[:resource_group]).each { |vm| + found[Id.new(vm.id)] = vm + } + else + MU::Cloud::Azure.compute(credentials: args[:credentials]).virtual_machines.list_all.each { |vm| + found[Id.new(vm.id)] = vm + } + end + end + + found + end + + # Return a description of this resource appropriate for deployment + # metadata. Arguments reflect the return values of the MU::Cloud::[Resource].describe method + def notify + MU.structToHash(cloud_desc) + end + + # Called automatically by {MU::Deploy#createResources} + def groom + create_update + + MU::MommaCat.lock(@cloud_id.to_s+"-groom") + + node, config, deploydata = describe(cloud_id: @cloud_id) + + if node.nil? or node.empty? + raise MuError, "MU::Cloud::Azure::Server.groom was called without a mu_name" + end + + # Make double sure we don't lose a cached mu_windows_name value. + if windows? or !@config['active_directory'].nil? + if @mu_windows_name.nil? + @mu_windows_name = deploydata['mu_windows_name'] + end + end + + @groomer.saveDeployData + + begin + @groomer.run(purpose: "Full Initial Run", max_retries: 15) + rescue MU::Groomer::RunError + MU.log "Proceeding after failed initial Groomer run, but #{node} may not behave as expected!", MU::WARN + end + + if !@config['create_image'].nil? and !@config['image_created'] + img_cfg = @config['create_image'] + # Scrub things that don't belong on an AMI + session = getSSHSession + sudo = purgecmd = "" + sudo = "sudo" if @config['ssh_user'] != "root" + if windows? + purgecmd = "rm -rf /cygdrive/c/mu_installed_chef" + else + purgecmd = "rm -rf /opt/mu_installed_chef" + end + if img_cfg['image_then_destroy'] + if windows? + purgecmd = "rm -rf /cygdrive/c/chef/ /home/#{@config['windows_admin_username']}/.ssh/authorized_keys /home/Administrator/.ssh/authorized_keys /cygdrive/c/mu-installer-ran-updates /cygdrive/c/mu_installed_chef" + # session.exec!("powershell -Command \"& {(Get-WmiObject -Class Win32_Product -Filter \"Name='UniversalForwarder'\").Uninstall()}\"") + else + purgecmd = "#{sudo} rm -rf /root/.ssh/authorized_keys /etc/ssh/ssh_host_*key* /etc/chef /etc/opscode/* /.mu-installer-ran-updates /var/chef /opt/mu_installed_chef /opt/chef ; #{sudo} sed -i 's/^HOSTNAME=.*//' /etc/sysconfig/network" + end + end + session.exec!(purgecmd) + session.close + stop + image_id = MU::Cloud::Azure::Server.createImage( + name: MU::Cloud::Azure.nameStr(@mu_name), + instance_id: @cloud_id, + region: @config['region'], + storage: @config['storage'], + family: ("mu-"+@config['platform']+"-"+MU.environment).downcase, + project: @project_id, + exclude_storage: img_cfg['image_exclude_storage'], + make_public: img_cfg['public'], + tags: @config['tags'], + zone: @config['availability_zone'], + credentials: @config['credentials'] + ) + @deploy.notify("images", @config['name'], {"image_id" => image_id}) + @config['image_created'] = true + if img_cfg['image_then_destroy'] + MU.log "Image #{image_id} ready, removing source node #{node}" + MU::Cloud::Azure.compute(credentials: @config['credentials']).delete_instance( + @project_id, + @config['availability_zone'], + @cloud_id + ) + destroy + else + start + end + end + + MU::MommaCat.unlock(@cloud_id.to_s+"-groom") + end + + # Create an image out of a running server. Requires either the name of a MU resource in the current deployment, or the cloud provider id of a running instance. + # @param name [String]: The MU resource name of the server to use as the basis for this image. + # @param instance_id [String]: The cloud provider resource identifier of the server to use as the basis for this image. + # @param storage [Hash]: The storage devices to include in this image. + # @param exclude_storage [Boolean]: Do not include the storage device profile of the running instance when creating this image. + # @param region [String]: The cloud provider region + # @param tags [Array]: Extra/override tags to apply to the image. + # @return [String]: The cloud provider identifier of the new machine image. + def self.createImage(name: nil, instance_id: nil, storage: {}, exclude_storage: false, project: nil, make_public: false, tags: [], region: nil, family: "mu", zone: MU::Cloud::Azure.listAZs.sample, credentials: nil) + end + + # Return the IP address that we, the Mu server, should be using to access + # this host via the network. Note that this does not factor in SSH + # bastion hosts that may be in the path, see getSSHConfig if that's what + # you need. + def canonicalIP + mu_name, config, deploydata = describe(cloud_id: @cloud_id) + + if !cloud_desc + raise MuError, "Couldn't retrieve cloud descriptor for server #{self}" + end + + private_ips = [] + public_ips = [] + + cloud_desc.network_profile.network_interfaces.each { |iface| + iface_id = Id.new(iface.is_a?(Hash) ? iface['id'] : iface.id) + iface_desc = MU::Cloud::Azure.network(credentials: @credentials).network_interfaces.get(@resource_group, iface_id.to_s) + iface_desc.ip_configurations.each { |ipcfg| + private_ips << ipcfg.private_ipaddress + if ipcfg.respond_to?(:public_ipaddress) and ipcfg.public_ipaddress + ip_id = Id.new(ipcfg.public_ipaddress.id) + ip_desc = MU::Cloud::Azure.network(credentials: @credentials).public_ipaddresses.get(@resource_group, ip_id.to_s) + if ip_desc + public_ips << ip_desc.ip_address + end + end + } + } + + # Our deploydata gets corrupted often with server pools, this will cause us to use the wrong IP to identify a node + # which will cause us to create certificates, DNS records and other artifacts with incorrect information which will cause our deploy to fail. + # The cloud_id is always correct so lets use 'cloud_desc' to get the correct IPs + if MU::Cloud::Azure::VPC.haveRouteToInstance?(cloud_desc, credentials: @config['credentials']) or public_ips.size == 0 + @config['canonical_ip'] = private_ips.first + return private_ips.first + else + @config['canonical_ip'] = public_ips.first + return public_ips.first + end + end + + # return [String]: A password string. + def getWindowsAdminPassword + end + + # Add a volume to this instance + # @param dev [String]: Device name to use when attaching to instance + # @param size [String]: Size (in gb) of the new volume + # @param type [String]: Cloud storage type of the volume, if applicable + # @param delete_on_termination [Boolean]: Value of delete_on_termination flag to set + def addVolume(dev, size, type: "pd-standard", delete_on_termination: false) + end + + # Determine whether the node in question exists at the Cloud provider + # layer. + # @return [Boolean] + def active? + !cloud_desc.nil? + end + + # Does this resource type exist as a global (cloud-wide) artifact, or + # is it localized to a region/zone? + # @return [Boolean] + def self.isGlobal? + false + end + + # Denote whether this resource implementation is experiment, ready for + # testing, or ready for production use. + def self.quality + MU::Cloud::BETA + end + + # Remove all instances associated with the currently loaded deployment. Also cleans up associated volumes, droppings in the MU master's /etc/hosts and ~/.ssh, and in whatever Groomer was used. + # @param noop [Boolean]: If true, will only print what would be done + # @param ignoremaster [Boolean]: If true, will remove resources not flagged as originating from this Mu server + # @param region [String]: The cloud provider region + # @return [void] + def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) + end + + # Cloud-specific configuration properties. + # @param config [MU::Config]: The calling MU::Config object + # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource + def self.schema(config) + toplevel_required = [] + hosts_schema = MU::Config::CIDR_PRIMITIVE + hosts_schema["pattern"] = "^(\\d+\\.\\d+\\.\\d+\\.\\d+\/[0-9]{1,2}|\\*)$" + schema = { + "roles" => MU::Cloud::Azure::User.schema(config)[1]["roles"], + "ingress_rules" => { + "items" => { + "properties" => { + "hosts" => { + "type" => "array", + "items" => hosts_schema + } + } + } + } + } + [toplevel_required, schema] + end + + # Confirm that the given instance size is valid for the given region. + # If someone accidentally specified an equivalent size from some other cloud provider, return something that makes sense. If nothing makes sense, return nil. + # @param size [String]: Instance type to check + # @param region [String]: Region to check against + # @return [String,nil] + def self.validateInstanceType(size, region) + size = size.dup.to_s + types = (MU::Cloud::Azure.listInstanceTypes(region))[region] + if types and (size.nil? or !types.has_key?(size)) + # See if it's a type we can approximate from one of the other clouds + foundmatch = false + MU::Cloud.availableClouds.each { |cloud| + next if cloud == "Azure" + cloudbase = Object.const_get("MU").const_get("Cloud").const_get(cloud) + foreign_types = (cloudbase.listInstanceTypes).values.first + if foreign_types.size == 1 + foreign_types = foreign_types.values.first + end + if foreign_types and foreign_types.size > 0 and foreign_types.has_key?(size) + vcpu = foreign_types[size]["vcpu"] + mem = foreign_types[size]["memory"] + ecu = foreign_types[size]["ecu"] + types.keys.sort.reverse.each { |type| + features = types[type] + next if ecu == "Variable" and ecu != features["ecu"] + next if features["vcpu"] != vcpu + if (features["memory"] - mem.to_f).abs < 0.10*mem + foundmatch = true + MU.log "You specified #{cloud} instance type '#{size}.' Approximating with Azure Compute type '#{type}.'", MU::WARN + size = type + break + end + } + end + break if foundmatch + } + + if !foundmatch + MU.log "Invalid size '#{size}' for Azure Compute instance in #{region}. Supported types:", MU::ERR, details: types.keys.sort.join(", ") +exit + return nil + end + end + size + end + + + # Cloud-specific pre-processing of {MU::Config::BasketofKittens::servers}, bare and unvalidated. + # @param server [Hash]: The resource to process and validate + # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member + # @return [Boolean]: True if validation succeeded, False otherwise + def self.validateConfig(server, configurator) + ok = true + + server['region'] ||= MU::Cloud::Azure.myRegion(server['credentials']) + server['ssh_user'] ||= "muadmin" + + server['size'] = validateInstanceType(server["size"], server["region"]) + ok = false if server['size'].nil? + + if server['image_id'].nil? + img_id = MU::Cloud.getStockImage("Azure", platform: server['platform']) + if img_id + server['image_id'] = configurator.getTail("server"+server['name']+"Image", value: img_id, prettyname: "server"+server['name']+"Image") + else + MU.log "No image specified for #{server['name']} and no default available for platform #{server['platform']}", MU::ERR, details: server + ok = false + end + end + + image_desc = MU::Cloud::Azure::Server.fetchImage(server['image_id'].to_s, credentials: server['credentials'], region: server['region']) + if image_desc.plan + terms = MU::Cloud::Azure.marketplace(credentials: @credentials).marketplace_agreements.get(image_desc.plan.publisher, image_desc.plan.product, image_desc.plan.name) + if !terms.accepted + MU.log "Deploying #{server['name']} will automatically agree to the licensing terms for #{terms.product}", MU::NOTICE, details: terms.license_text_link + end + end + + if !image_desc + MU.log "Failed to locate an Azure VM image for #{server['name']} from #{server['image_id']} in #{server['region']}", MU::ERR + ok = false + else + server['image_id'] = image_desc.id + end + + if server['add_firewall_rules'] and server['add_firewall_rules'].size == 0 + MU.log "Azure resources can only have one security group per network interface; use ingress_rules instead of add_firewall_rules.", MU::ERR + ok = false + end + + # Azure doesn't have default VPCs, so our fallback approach will be + # to generate one on the fly. + if server['vpc'].nil? + vpc = { + "name" => server['name']+"vpc", + "cloud" => "Azure", + "region" => server['region'], + "credentials" => server['credentials'] + } + if !configurator.insertKitten(vpc, "vpcs", true) + ok = false + end + server['dependencies'] ||= [] + + server['dependencies'] << { + "type" => "vpc", + "name" => server['name']+"vpc" + } +# XXX what happens if there's no natstion here? + server['dependencies'] << { + "type" => "server", + "name" => server['name']+"vpc-natstion", + "phase" => "groom" + } + server['vpc'] = { + "name" => server['name']+"vpc", + "subnet_pref" => "private" + } + end + server['vpc']['subnet_pref'] ||= "private" + + svcacct_desc = { + "name" => server["name"]+"user", + "region" => server["region"], + "type" => "service", + "cloud" => "Azure", + "create_api_key" => true, + "credentials" => server["credentials"], + "roles" => server["roles"] + } + server['dependencies'] ||= [] + server['dependencies'] << { + "type" => "user", + "name" => server["name"]+"user" + } + + ok = false if !configurator.insertKitten(svcacct_desc, "users") + + ok + end + + def self.diskConfig(config, create = true, disk_as_url = true, credentials: nil) + end + + # Retrieve the cloud descriptor for an Azure machine image + # @param image_id [String]: A full Azure resource id, or a shorthand string like OpenLogic/CentOS/7.6/7.6.20190808. The third and fourth fields (major version numbers and release numbers, by convention) can be partial, and the release number can be omitted entirely. We default to the most recent matching release when applicable. + # @param credentials [String] + # @return [Azure::Compute::Mgmt::V2019_03_01::Models::VirtualMachineImage] + def self.fetchImage(image_id, credentials: nil, region: MU::Cloud::Azure.myRegion) + + publisher = offer = sku = version = nil + if image_id.match(/\/Subscriptions\/[^\/]+\/Providers\/Microsoft.Compute\/Locations\/([^\/]+)\/Publishers\/([^\/]+)\/ArtifactTypes\/VMImage\/Offers\/([^\/]+)\/Skus\/([^\/]+)\/Versions\/([^\/]+)$/) + region = Regexp.last_match[1] + publisher = Regexp.last_match[2] + offer = Regexp.last_match[3] + sku = Regexp.last_match[4] + version = Regexp.last_match[5] + return MU::Cloud::Azure.compute(credentials: credentials).virtual_machine_images.get(region, publisher, offer, sku, version) + else + publisher, offer, sku, version = image_id.split(/\//) + end + if !publisher or !offer or !sku + raise MuError, "Azure image_id #{image_id} was invalid" + end + + skus = MU::Cloud::Azure.compute(credentials: credentials).virtual_machine_images.list_skus(region, publisher, offer).map { |s| s.name } + + if !skus.include?(sku) + skus.sort { |a, b| MU.version_sort(a, b) }.reverse.each { |s| + if s.match(/^#{Regexp.quote(sku)}/) + sku = s + break + end + } + end + + versions = MU::Cloud::Azure.compute(credentials: credentials).virtual_machine_images.list(region, publisher, offer, sku).map { |v| v.name } + if versions.nil? or versions.empty? + MU.log "Azure API returned empty machine image version list for publisher #{publisher} offer #{offer} sku #{sku}", MU::ERR + return nil + end + + if version.nil? + version = versions.sort { |a, b| MU.version_sort(a, b) }.reverse.first + elsif !versions.include?(version) + versions.sort { |a, b| MU.version_sort(a, b) }.reverse.each { |v| + if v.match(/^#{Regexp.quote(version)}/) + version = v + break + end + } + end + + MU::Cloud::Azure.compute(credentials: credentials).virtual_machine_images.get(region, publisher, offer, sku, version) + end + + private + + def create_update + ipcfg = MU::Cloud::Azure.network(:NetworkInterfaceIPConfiguration).new + ipcfg.name = @mu_name + ipcfg.private_ipallocation_method = MU::Cloud::Azure.network(:IPAllocationMethod)::Dynamic + + private_nets = @vpc.subnets.reject { |s| !s.private? } + public_nets = @vpc.subnets.reject { |s| s.private? } + + stubnet = if @config['vpc']['subnet_id'] + useme = nil + @vpc.subnets.each { |s| + if s.cloud_id.to_s == @config['vpc']['subnet_id'] + useme = s + break + end + } + if !useme + raise MuError, "Failed to locate subnet #{@config['vpc']['subnet_id']} in VPC #{@vpc.to_s}" + end + useme + elsif @config['vpc']['subnet_pref'] == "private" or + @config['vpc']['subnet_pref'] == "all_private" + if private_nets.size == 0 + raise MuError, "Server #{@mu_name} wanted a private subnet, but there are none in #{@vpc.to_s}" + end + private_nets.sample + elsif @config['vpc']['subnet_pref'] == "public" or + @config['vpc']['subnet_pref'] == "all_public" + if public_nets.size == 0 + raise MuError, "Server #{@mu_name} wanted a public subnet, but there are none in #{@vpc.to_s}" + end + public_nets.sample + end + + # Allocate a public IP if we asked for one + if @config['associate_public_ip'] or !stubnet.private? + pubip_obj = MU::Cloud::Azure.network(:PublicIPAddress).new + pubip_obj.public_ipallocation_method = MU::Cloud::Azure.network(:IPAllocationMethod)::Dynamic + pubip_obj.location = @config['region'] + pubip_obj.tags = @tags + resp = MU::Cloud::Azure.network(credentials: @credentials).public_ipaddresses.create_or_update(@resource_group, @mu_name, pubip_obj) + ipcfg.public_ipaddress = resp + end + + ipcfg.subnet = MU::Cloud::Azure.network(:Subnet).new + ipcfg.subnet.id = stubnet.cloud_desc.id + + sg = @deploy.findLitterMate(type: "firewall_rule", name: "server"+@config['name']) + + iface_obj = MU::Cloud::Azure.network(:NetworkInterface).new + iface_obj.location = @config['region'] + iface_obj.tags = @tags + iface_obj.primary = true + iface_obj.network_security_group = sg.cloud_desc if sg + iface_obj.enable_ipforwarding = !@config['src_dst_check'] + iface_obj.ip_configurations = [ipcfg] + MU.log "Creating network interface #{@mu_name}", MU::DEBUG, details: iface_obj + iface = MU::Cloud::Azure.network(credentials: @credentials).network_interfaces.create_or_update(@resource_group, @mu_name, iface_obj) + + img_obj = MU::Cloud::Azure.compute(:ImageReference).new + @config['image_id'].match(/\/Subscriptions\/[^\/]+\/Providers\/Microsoft.Compute\/Locations\/[^\/]+\/Publishers\/([^\/]+)\/ArtifactTypes\/VMImage\/Offers\/([^\/]+)\/Skus\/([^\/]+)\/Versions\/([^\/]+)$/) + img_obj.publisher = Regexp.last_match[1] + img_obj.offer = Regexp.last_match[2] + img_obj.sku = Regexp.last_match[3] + img_obj.version = Regexp.last_match[4] + + hw_obj = MU::Cloud::Azure.compute(:HardwareProfile).new + hw_obj.vm_size = @config['size'] + + os_obj = MU::Cloud::Azure.compute(:OSProfile).new + os_obj.admin_username = @config['ssh_user'] + os_obj.computer_name = @mu_name + if windows? + win_obj = MU::Cloud::Azure.compute(:WindowsConfiguration).new + os_obj.windows_configuration = win_obj + else + key_obj = MU::Cloud::Azure.compute(:SshPublicKey).new + key_obj.key_data = @deploy.ssh_public_key + key_obj.path = "/home/#{@config['ssh_user']}/.ssh/authorized_keys" + + ssh_obj = MU::Cloud::Azure.compute(:SshConfiguration).new + ssh_obj.public_keys = [key_obj] + + lnx_obj = MU::Cloud::Azure.compute(:LinuxConfiguration).new + lnx_obj.disable_password_authentication = true + lnx_obj.ssh = ssh_obj + + os_obj.linux_configuration = lnx_obj + end + + vm_id_obj = MU::Cloud::Azure.compute(:VirtualMachineIdentity).new + vm_id_obj.type = "UserAssigned" + svc_acct = @deploy.findLitterMate(type: "user", name: @config['name']+"user") + raise MuError, "Failed to locate service account #{@config['name']}user" if !svc_acct + vm_id_obj.user_assigned_identities = { + svc_acct.cloud_desc.id => svc_acct.cloud_desc + } + + vm_obj = MU::Cloud::Azure.compute(:VirtualMachine).new + vm_obj.location = @config['region'] + vm_obj.tags = @tags + vm_obj.network_profile = MU::Cloud::Azure.compute(:NetworkProfile).new + vm_obj.network_profile.network_interfaces = [iface] + vm_obj.hardware_profile = hw_obj + vm_obj.os_profile = os_obj + vm_obj.identity = vm_id_obj + vm_obj.storage_profile = MU::Cloud::Azure.compute(:StorageProfile).new + vm_obj.storage_profile.image_reference = img_obj + + image_desc = MU::Cloud::Azure::Server.fetchImage(@config['image_id'].to_s, credentials: @config['credentials'], region: @config['region']) +# XXX do this as a catch around instance creation so we don't waste API calls + if image_desc.plan + terms = MU::Cloud::Azure.marketplace(credentials: @credentials).marketplace_agreements.get(image_desc.plan.publisher, image_desc.plan.product, image_desc.plan.name) + if !terms.accepted + MU.log "Agreeing to licensing terms of #{terms.product}", MU::NOTICE + begin +# XXX this doesn't actually work as documented + MU::Cloud::Azure.marketplace(credentials: @credentials).marketplace_agreements.sign(image_desc.plan.publisher, image_desc.plan.product, image_desc.plan.name) + rescue Exception => e + MU.log e.message, MU::ERR + vm_obj.plan = nil + end + end + vm_obj.plan = image_desc.plan + end + if @config['storage'] + vm_obj.storage_profile.data_disks = [] + @config['storage'].each { |disk| + lun = if disk['device'].is_a?(Integer) or + disk['device'].match(/^\d+$/) + disk['device'].to_i + else + disk['device'].match(/([a-z])[^a-z]*$/i) + # map the last letter of the requested device to a numeric lun + # so that a => 1, b => 2, and so on + Regexp.last_match[1].downcase.encode("ASCII-8BIT").ord - 96 + end + disk_obj = MU::Cloud::Azure.compute(:DataDisk).new + disk_obj.disk_size_gb = disk['size'] + disk_obj.lun = lun + disk_obj.name = @mu_name+disk['device'].to_s.gsub(/[^\w\-._]/, '_').upcase + disk_obj.create_option = MU::Cloud::Azure.compute(:DiskCreateOptionTypes)::Empty + vm_obj.storage_profile.data_disks << disk_obj + } + end + + +if !@cloud_id +# XXX actually guard this correctly + MU.log "Creating VM #{@mu_name}", details: vm_obj + begin + vm = MU::Cloud::Azure.compute(credentials: @credentials).virtual_machines.create_or_update(@resource_group, @mu_name, vm_obj) + @cloud_id = Id.new(vm.id) + rescue ::MU::Cloud::Azure::APIError => e + if e.message.match(/InvalidParameter: /) + MU.log e.message, MU::ERR, details: vm_obj + end + raise e + end +end + + end + + + end #class + end #class + end +end #module diff --git a/modules/mu/clouds/azure/user.rb b/modules/mu/clouds/azure/user.rb new file mode 100644 index 000000000..9e396dcfb --- /dev/null +++ b/modules/mu/clouds/azure/user.rb @@ -0,0 +1,257 @@ +# Copyright:: Copyright (c) 2018 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module MU + class Cloud + class Azure + # A user as configured in {MU::Config::BasketofKittens::users} + class User < MU::Cloud::User + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + + if !mu_name.nil? + @mu_name = mu_name + @cloud_id = Id.new(cloud_desc.id) if @cloud_id and cloud_desc + else + @mu_name ||= @deploy.getResourceName(@config["name"], max_length: 31) + end + + end + + # Called automatically by {MU::Deploy#createResources} + def create + @config['region'] ||= MU::Cloud::Azure.myRegion(@config['credentials']) + rgroup_name = @deploy.deploy_id+"-"+@config['region'].upcase + + tags = {} + if !@config['scrub_mu_isms'] + tags = MU::MommaCat.listStandardTags + end + if @config['tags'] + @config['tags'].each { |tag| + tags[tag['key']] = tag['value'] + } + end + + if @config['type'] == "interactive" + raise Mu::MuError, "I don't know how to make interactive users in Azure yet" + else + ident_obj = MU::Cloud::Azure.serviceaccts(:Identity).new +# ident_obj.name = @mu_name + ident_obj.location = @config['region'] + ident_obj.tags = tags + begin + MU.log "Creating service account #{@mu_name}" + resp = MU::Cloud::Azure.serviceaccts(credentials: @config['credentials']).user_assigned_identities.create_or_update(rgroup_name, @mu_name, ident_obj) + @cloud_id = Id.new(resp.id) + rescue ::MsRestAzure::AzureOperationError => e + MU::Cloud::Azure.handleError(e) + end + + begin + sleep 1 + end while cloud_desc(use_cache: false).nil? or cloud_desc.client_id.nil? + + end + end + + # If we're a managed service identity or otherwise have a URL for + # fetching our client secret, fetch it and return it. + # XXX this doesn't work, and may not be intended to + # @return [String] + def getSecret + if cloud_desc and cloud_desc.client_secret_url + cred_hash = MU::Cloud::Azure.getSDKOptions(@credentials) + + token_provider = MsRestAzure::ApplicationTokenProvider.new( + cred_hash[:tenant_id], + cred_hash[:client_id], + cred_hash[:client_secret] + ) + cred_obj = MsRest::TokenCredentials.new(token_provider) + + client = ::MsRest::ServiceClient.new(cred_obj) + cloud_desc.client_secret_url.match(/^(http.*?\.azure\.net)(\/.*)/) + base = Regexp.last_match[1] + path = Regexp.last_match[2] +#MU.log "Calling into #{base} #{path}" + promise = client.make_request_async( + cloud_desc.client_secret_url, + :get, + path + ) + + # XXX this is async, need to stop and wait somehow + promise.then do | result| + resp = result.response +# MU.log "RESPONSE", MU::WARN, details: resp + end + end + nil + end + + # Called automatically by {MU::Deploy#createResources} + def groom + rgroup_name = @deploy.deploy_id+"-"+@config['region'].upcase + if @config['roles'] + @config['roles'].each { |role| + MU::Cloud::Azure::Role.assignTo(cloud_desc.principal_id, role_name: role, credentials: @config['credentials']) + } + end + end + + # Return the metadata for this user configuration + # @return [Hash] + def notify + description = MU.structToHash(cloud_desc) + if description + description.delete(:etag) + return description + end + { + } + end + + # Does this resource type exist as a global (cloud-wide) artifact, or + # is it localized to a region/zone? + # @return [Boolean] + def self.isGlobal? + false + end + + # Denote whether this resource implementation is experiment, ready for + # testing, or ready for production use. + def self.quality + MU::Cloud::ALPHA + end + + # Stub method. Azure resources are cleaned up by removing the parent + # resource group. + # @return [void] + def self.cleanup(**args) + end + + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) + found = {} + +# XXX Had to register Microsoft.ApiManagement at https://portal.azure.com/#@eglobaltechlabs.onmicrosoft.com/resource/subscriptions/3d20ddd8-4652-4074-adda-0d127ef1f0e0/resourceproviders +# ffs automate this process, it's just like API enabling in GCP + + # Azure resources are namedspaced by resource group. If we weren't + # told one, we may have to search all the ones we can see. + resource_groups = if args[:resource_group] + [args[:resource_group]] + elsif args[:cloud_id] and args[:cloud_id].is_a?(MU::Cloud::Azure::Id) + [args[:cloud_id].resource_group] + else + MU::Cloud::Azure.resources(credentials: args[:credentials]).resource_groups.list.map { |rg| rg.name } + end + + if args[:cloud_id] + id_str = args[:cloud_id].is_a?(MU::Cloud::Azure::Id) ? args[:cloud_id].name : args[:cloud_id] + resource_groups.each { |rg| + resp = MU::Cloud::Azure.serviceaccts(credentials: args[:credentials]).user_assigned_identities.get(rg, id_str) + found[Id.new(resp.id)] = resp if resp + } + else + if args[:resource_group] + MU::Cloud::Azure.serviceaccts(credentials: args[:credentials]).user_assigned_identities.list_by_resource_group.each { |ident| + found[Id.new(ident.id)] = ident + } + else + MU::Cloud::Azure.serviceaccts(credentials: args[:credentials]).user_assigned_identities.list_by_subscription.each { |ident| + found[Id.new(ident.id)] = ident + } + end + end + + found + end + + # Cloud-specific configuration properties. + # @param config [MU::Config]: The calling MU::Config object + # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource + def self.schema(config) + toplevel_required = [] + schema = { + "region" => MU::Config.region_primitive, + "name" => { + "type" => "string", + "description" => "The name of a account to create. Currently, +service+ is the only account type we support in Azure." + }, + "type" => { + "type" => "string", + "description" => "'service' will create a service account (machine credentials) and generate API keys", + "enum" => ["service"] + }, + "roles" => { + "type" => "array", + "description" => "One or more Azure Authorization roles to associate with this resource.", + "default" => ["Reader"], + "items" => { + "type" => "string", + "description" => "One or more Azure Authorization roles to associate with this resource. If no roles are specified, we default to +Reader+, which permits read-only access subscription-wide." + } + } + } + [toplevel_required, schema] + end + + # Cloud-specific pre-processing of {MU::Config::BasketofKittens::users}, bare and unvalidated. + # @param user [Hash]: The resource to process and validate + # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member + # @return [Boolean]: True if validation succeeded, False otherwise + def self.validateConfig(user, configurator) + ok = true + user['region'] ||= MU::Cloud::Azure.myRegion(user['credentials']) + +# if user['groups'] and user['groups'].size > 0 and +# !MU::Cloud::Azure.credConfig(user['credentials'])['masquerade_as'] +# MU.log "Cannot change Azure group memberships in non-GSuite environments.\nVisit https://groups.google.com to manage groups.", MU::ERR +# ok = false +# end + + if user['type'] != "service" and user["create_api_key"] + MU.log "Only service accounts can have API keys in Azure", MU::ERR + ok = false + end + + if user['type'] != "service" + MU.log "Human accounts not yet supported in Azure::User", MU::ERR + ok = false + end + + ok + end + + private + + def bind_human_user + end + + end + end + end +end diff --git a/modules/mu/clouds/azure/userdata/README.md b/modules/mu/clouds/azure/userdata/README.md new file mode 100644 index 000000000..a3f53f958 --- /dev/null +++ b/modules/mu/clouds/azure/userdata/README.md @@ -0,0 +1,4 @@ + +Baseline CloudInit userdata scripts for MU nodes to self-configure. + +See also: https://help.ubuntu.com/community/CloudInit diff --git a/modules/mu/clouds/azure/userdata/linux.erb b/modules/mu/clouds/azure/userdata/linux.erb new file mode 100644 index 000000000..d2664b162 --- /dev/null +++ b/modules/mu/clouds/azure/userdata/linux.erb @@ -0,0 +1,141 @@ +#!/bin/sh +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +updates_run=0 +need_reboot=0 +instance_id="`curl -H Metadata:tttp://169.254.169.254/metadata/instance/compute/name?api-version=2017-08-01&format=text'`" + +for f in /etc/rc.local /etc/rc.d/rc.local;do + if [ -f $f ];then + chmod 755 $f + fi +done + +if [ -f /etc/debian_version ];then + if ! grep '^/bin/sh /var/lib/cloud/instance/user-data.txt$' /etc/rc.local > /dev/null;then + echo "/bin/sh /var/lib/cloud/instance/user-data.txt" >> /etc/rc.local + fi + apt-get update -y + if [ ! -f /usr/bin/curl ] ;then /usr/bin/apt-get --fix-missing -y install curl;fi +<% if !$mu.skipApplyUpdates %> + if [ ! -f /.mu-installer-ran-updates ];then + service ssh stop + apt-get --fix-missing -y upgrade + if [ $? -eq 0 ] + then + echo "Successfully updated packages" + updates_run=1 + else + echo "FAILED PACKAGE UPDATE" >&2 + fi + # Proceed regardless + touch /.mu-installer-ran-updates + + # XXX this logic works on Ubuntu, is it Debian-friendly? + latest_kernel="`ls -1 /boot/vmlinuz-* | sed -r 's/^\/boot\/vmlinuz-//' | tail -1`" + running_kernel="`uname -r`" + if [ "$running_kernel" != "$latest_kernel" -a "$latest_kernel" != "" ];then + need_reboot=1 + else + service ssh start + fi + fi +<% end %> +elif [ -x /usr/bin/yum ];then + version=`/bin/rpm -qa \*-release | grep -Ei "redhat|centos" | cut -d"-" -f3` + if [ -z "$version" ];then + amazon_version=`/bin/rpm -qa \*-release | grep -Ei "system-release"| cut -d"-" -f3 | cut -d"." -f1` + if [ "$amazon_version" == "2014" ] || [ "$amazon_version" == "2015" ] || [ "$amazon_version" == "2016" ];then + version=6 + fi + fi + if [ $version -eq 7 ];then + userdata_dir="/var/lib/cloud/instances/$instance_id" + else + userdata_dir="/var/lib/cloud/instance" + fi + if ! grep "^/bin/sh $userdata_dir/user-data.txt$" /etc/rc.d/rc.local > /dev/null;then + echo "/bin/sh $userdata_dir/user-data.txt" >> /etc/rc.d/rc.local + fi + + sed -i 's/^Defaults.*requiretty$/Defaults !requiretty/' /etc/sudoers + + chmod 755 /etc/rc.d/rc.local + if [ ! -f /usr/bin/curl ] ;then /usr/bin/yum -y install curl;fi + # Ugh, rando EPEL mirror + if [ ! -f /etc/yum.repos.d/epel.repo ];then + /bin/rpm -ivh http://mirror.metrocast.net/fedora/epel/epel-release-latest-$version.noarch.rpm + fi +<% if !$mu.skipApplyUpdates %> + if [ ! -f /.mu-installer-ran-updates ];then + service sshd stop + kernel_update=`yum list updates | grep kernel` + yum -y update + if [ $? -eq 0 ] + then + echo "Successfully updated packages" + updates_run=1 + else + echo "FAILED PACKAGE UPDATE" >&2 + fi + # Proceed regardless + touch /.mu-installer-ran-updates + if [ -n "$kernel_update" ]; then + need_reboot=1 + else + service sshd start + fi + fi +<% end %> +fi + +umask 0077 + +# Install Chef now, because why not? +if [ ! -f /opt/chef/embedded/bin/ruby ];then + curl https://www.chef.io/chef/install.sh > chef-install.sh + set +e + # We may run afoul of a synchronous bootstrap process doing the same thing. So + # wait until we've managed to run successfully. + while ! sh chef-install.sh -v <%= $mu.chefVersion %>;do + sleep 10 + done + touch /opt/mu_installed_chef + set -e +fi + +<% if !$mu.skipApplyUpdates %> +if [ "$need_reboot" == "1" ];then + shutdown -r now "Applying new kernel" +fi +<% end %> + +gsutil cp gs://<%= $mu.adminBucketName %>/<%= $mu.muID %>-secret . + +echo ' +require "openssl" +require "base64" +key = OpenSSL::PKey::RSA.new(Base64.urlsafe_decode64("<%= $mu.deployKey %>")) +print Base64.urlsafe_encode64(key.public_encrypt(File.read("<%= $mu.muID %>-secret"))) +' > encrypt_deploy_secret.rb + +deploykey="<%= $mu.deployKey %>" + +# Make double-sure sshd is actually up +service sshd restart + +/usr/bin/curl -k --data mu_id="<%= $mu.muID %>" --data mu_resource_name="<%= $mu.resourceName %>" --data mu_resource_type="<%= $mu.resourceType %>" --data mu_instance_id="$instance_id" --data mu_bootstrap="1" --data mu_user="<%= $mu.muUser %>" --data mu_deploy_secret="`/opt/chef/embedded/bin/ruby encrypt_deploy_secret.rb`" https://<%= $mu.publicIP %>:<%= $mu.mommaCatPort %>/ +/bin/rm -f <%= $mu.muID %>-secret mu_deploy_key.pub chef-install.sh encrypt_deploy_secret.rb +touch /.mu_userdata_complete diff --git a/modules/mu/clouds/azure/userdata/windows.erb b/modules/mu/clouds/azure/userdata/windows.erb new file mode 100644 index 000000000..e6b3045ca --- /dev/null +++ b/modules/mu/clouds/azure/userdata/windows.erb @@ -0,0 +1,275 @@ + +Set-ExecutionPolicy Unrestricted -Force -Scope CurrentUser + +$sshdUser = "sshd_service" +$tmp = "$env:Temp\mu-userdata" +mkdir $tmp +$logfile = "c:/Mu-Bootstrap-$([Environment]::UserName).log" +$basedir = 'c:/bin' +$cygwin_dir = "$basedir/cygwin" +$username = (whoami).Split('\')[1] +$WebClient = New-Object System.Net.WebClient +$awsmeta = "http://169.254.169.254/latest" +$pydir = 'c:\bin\python\python27' +$pyv = '2.7.14' +$env:Path += ";$pydir\Scripts;$pydir" + +function log +{ + Write-Host $args + Add-Content "c:/Mu-Bootstrap-$([Environment]::UserName).log" "$(Get-Date -f MM-dd-yyyy_HH:mm:ss) $args" + Add-Content "c:/Mu-Bootstrap-GLOBAL.log" "$(Get-Date -f MM-dd-yyyy_HH:mm:ss) $args" +} + +function fetchSecret([string]$file){ + log "Fetching s3://<%= $mu.adminBucketName %>/$file to $tmp/$file" + aws.cmd s3 cp s3://<%= $mu.adminBucketName %>/$file $tmp/$file +} + +function importCert([string]$cert, [string]$store){ + fetchSecret($cert) + if(!(Test-Path "$tmp/$cert")){ + return $null + } + # XXX guard better (check thumbprint & CN) + if($store -ne "Root"){ + Remove-Item -Path Cert:/LocalMachine/$store/* -Force -Recurse + } + if($cert -Match ".pfx$"){ + return Import-PfxCertificate -FilePath $tmp/$cert -CertStoreLocation Cert:\LocalMachine\$store + } else { + return Import-Certificate -FilePath $tmp/$cert -CertStoreLocation Cert:\LocalMachine\$store + } + Remove-Item -Force "$tmp/$cert" +} + +log "- Invoked as $([Environment]::UserName) (system started at $(Get-CimInstance -ClassName win32_operatingsystem | select lastbootuptime)) -" +<% if !$mu.skipApplyUpdates %> +If (!(Test-Path "c:/mu-installer-ran-updates")){ + Stop-Service -ErrorAction SilentlyContinue sshd +} +<% end %> +<% if $mu.platform != "win2k16" %> +If ([Environment]::OSVersion.Version.Major -lt 10) { + If ("$($myInvocation.MyCommand.Path)" -ne "$tmp/realuserdata_stripped.ps1"){ + $Error.Clear() + Invoke-WebRequest -Uri "$awsmeta/user-data" -OutFile $tmp/realuserdata.ps1 + while($Error.count -gt 0){ + $Error.Clear() + log "Failed to retrieve current userdata from $awsmeta/user-data, waiting 15s and retrying" + sleep 15 + Invoke-WebRequest -Uri "$awsmeta/user-data" -OutFile $tmp/realuserdata.ps1 + } + Get-Content $tmp/realuserdata.ps1 | Select-String -pattern '^#','^<' -notmatch | Set-Content $tmp/realuserdata_stripped.ps1 + If (Compare-Object (Get-Content $myInvocation.MyCommand.Path) (Get-Content $tmp/realuserdata_stripped.ps1)){ + log "Invoking $tmp/realuserdata.ps1 in lieu of $($myInvocation.MyCommand.Path)" + Invoke-Expression $tmp/realuserdata_stripped.ps1 + exit + } + } +} +<% end %> +$admin_username = (Get-WmiObject -Query 'Select * from Win32_UserAccount Where (LocalAccount=True and SID like "%-500")').name +log "Local admin: $admin_username" + +Add-Type -Assembly System.Web +$password = [Web.Security.Membership]::GeneratePassword(15,2) + +If (!(Test-Path $basedir)){ + mkdir $basedir +} + +<% if $mu.platform != "win2k16" %> +If ([Environment]::OSVersion.Version.Major -lt 10) { + If (!(Get-ScheduledTask -TaskName 'run-userdata')){ + log "Adding run-userdata scheduled task (user NT AUTHORITY\SYSTEM)" + Invoke-WebRequest -Uri "https://s3.amazonaws.com/cloudamatic/run-userdata_scheduledtask.xml" -OutFile $tmp/run-userdata_scheduledtask.xml + Register-ScheduledTask -Xml (Get-Content "$tmp/run-userdata_scheduledtask.xml" | out-string) -TaskName 'run-userdata' -Force -User ".\$admin_username" + } +} +<% end %> + +If (!(Test-Path "$pydir\python.exe")){ + If (!(Test-Path $tmp\python-$pyv.msi)){ + log "Downloading Python installer" + $WebClient.DownloadFile("https://www.python.org/ftp/python/$pyv/python-$pyv.msi","$tmp/python-$pyv.msi") + } + log "Running Python installer" + (Start-Process -FilePath msiexec -ArgumentList "/i $tmp\python-$pyv.msi /qn ALLUSERS=1 TARGETDIR=$pydir" -Wait -Passthru).ExitCode +} + +If (!(Test-Path "$pydir\Scripts\aws.cmd")){ + If (!(Test-Path $tmp/get-pip.py)){ + log "Downloading get-pip.py" + $WebClient.DownloadFile("https://bootstrap.pypa.io/get-pip.py","$tmp/get-pip.py") + } + python $tmp/get-pip.py + log "Running pip install awscli" + pip install awscli +} + +function removeChef($location){ + $install_chef = $false + $my_chef = (Get-ItemProperty $location | Where-Object {$_.DisplayName -like "chef client*"}).DisplayName + if ($my_chef) { + if ($my_chef -match '<%= $mu.chefVersion %>'.split('-')[0]) { + $install_chef = $false + } else{ + log "Uninstalling Chef" + $uninstall_string = (Get-ItemProperty $location | Where-Object {$_.DisplayName -like "chef client*"}).UninstallString + $uninstall_string = ($uninstall_string -Replace "msiexec.exe","" -Replace "/I","" -Replace "/X","").Trim() + $($uninstall_string -Replace '[\s\t]+', ' ').Split() | ForEach { + log "msiexec.exe /X $_ /gn" + start-process "msiexec.exe" -arg "/X $_ /qn" -Wait + } + $install_chef = $true + } + } + + return $install_chef +} + +If (!(Test-Path "c:\opscode\chef\embedded\bin\ruby.exe")){ + $install_chef = $true +} else { + if (removeChef("HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*")){ + $install_chef = $true + } elseif (removeChef("HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*")) { + $install_chef = $true + } else { + $install_chef = $false + } +} + +If ($install_chef){ + log "Installing Chef <%= $mu.chefVersion %>" + If (!(Test-Path $env:Temp/chef-installer-<%= $mu.chefVersion %>.msi)){ + log "Downloading Chef installer" + $WebClient.DownloadFile("https://www.chef.io/chef/download?p=windows&pv=2012&m=x86_64&v=<%= $mu.chefVersion %>","$env:Temp/chef-installer-<%= $mu.chefVersion %>.msi") + } + log "Running Chef installer" + (Start-Process -FilePath msiexec -ArgumentList "/i $env:Temp\chef-installer-<%= $mu.chefVersion %>.msi ALLUSERS=1 /le $env:Temp\chef-client-install.log /qn" -Wait -Passthru).ExitCode + Set-Content "c:/mu_installed_chef" "yup" +} + +fetchSecret("<%= $mu.muID %>-secret") +log "Encrypting Mu deploy secret" +$deploy_secret = & "c:\opscode\chef\embedded\bin\ruby" -ropenssl -rbase64 -e "key = OpenSSL::PKey::RSA.new(Base64.urlsafe_decode64('<%= $mu.deployKey %>'))" -e "print Base64.urlsafe_encode64(key.public_encrypt(File.read('$tmp\<%= $mu.muID %>-secret')))" + +function callMomma([string]$act) +{ + $params = @{mu_id='<%= $mu.muID %>';mu_resource_name='<%= $mu.resourceName %>';mu_resource_type='<%= $mu.resourceType %>';mu_instance_id="$awsid";mu_user='<%= $mu.muUser %>';mu_deploy_secret="$deploy_secret";$act="1"} + log "Calling Momma Cat at https://<%= $mu.publicIP %>:<%= $mu.mommaCatPort %> with $act" + [System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true} # XXX + $resp = Invoke-WebRequest -Uri https://<%= $mu.publicIP %>:<%= $mu.mommaCatPort %> -Method POST -Body $params + return $resp.Content +} + +$awsid=(New-Object System.Net.WebClient).DownloadString("$awsmeta/meta-data/instance-id") + +$credstr = callMomma "mu_windows_admin_creds" +$creds = $false +$real_admin_user = $admin_username +if($credstr){ + $credparts = $credstr.Split(";", 2) + $creds = New-Object System.Management.Automation.PSCredential($credparts[0], (ConvertTo-SecureString $credparts[1] -AsPlainText -Force)) + if($admin_username -ne $credparts[0]){ + if ((Get-WmiObject win32_computersystem).partofdomain -ne $true){ + (([adsi]("WinNT://./$admin_username, user")).psbase.invoke("SetPassword", $credparts[1])) + log "Changing local admin account from $admin_username to $($credparts[0])" + ([adsi]("WinNT://./$admin_username, user")).psbase.rename($credparts[0]) + $need_reboot = $TRUE + $real_admin_user = $credparts[0] + } ElseIf(!$admin_username){ + $admin_username = $credparts[0] + } + } ElseIf($creds){ + log "Setting $admin_username password" + (([adsi]("WinNT://./$admin_username, user")).psbase.invoke("SetPassword", $credparts[1])) + } +} else { + log "Failed to get credentials from Momma Cat for some reason $($credstr)" +} + +If (!(Test-Path $tmp/PSWindowsUpdate.zip)){ + If (!(Test-Path c:/Users/$admin_username/Documents/WindowsPowerShell/Modules)){ + mkdir c:/Users/$admin_username/Documents/WindowsPowerShell/Modules + } + + $WebClient.DownloadFile("https://s3.amazonaws.com/cloudamatic/PSWindowsUpdate.zip","$tmp/PSWindowsUpdate.zip") + Add-Type -A 'System.IO.Compression.FileSystem' + + If (!(Test-Path c:/windows/System32/WindowsPowerShell/v1.0/Modules/PSWindowsUpdate)){ + log "Extracting PSWindowsUpdate module to c:/windows/System32/WindowsPowerShell/v1.0/Modules" + [IO.Compression.ZipFile]::ExtractToDirectory("$tmp/PSWindowsUpdate.zip", "c:/windows/System32/WindowsPowerShell/v1.0/Modules") + } + If (!(Test-Path c:/Users/$admin_username/Documents/WindowsPowerShell/Modules/PSWindowsUpdate)){ + log "Extracting PSWindowsUpdate module to c:/Users/$admin_username/Documents/WindowsPowerShell" + [IO.Compression.ZipFile]::ExtractToDirectory("$tmp/PSWindowsUpdate.zip", "c:/Users/$admin_username/Documents/WindowsPowerShell/Modules") + } +} + +<% if !$mu.skipApplyUpdates %> +Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update" -Name AUOptions -Value 3 +If (!(Test-Path "c:/mu-installer-ran-updates")){ + log "Applying Windows updates" + Import-Module PSWindowsUpdate + Get-WUInstall -AcceptAll -IgnoreReboot + Start-Sleep -s 60 + If (Test-Path "HKLM:/SOFTWARE/Microsoft/Windows/CurrentVersion/WindowsUpdate/Auto Update/RebootRequired"){ + $need_reboot = $TRUE + } +} +<% end %> + +if((Get-WURebootStatus -Silent) -eq $true){ + log "Get-WURebootStatus says to reboot" + $need_reboot = $TRUE +} + +$muca = importCert "Mu_CA.pem" "Root" + +$myname = "<%= $mu.muID %>-<%= $mu.resourceName.upcase %>" + +$nodecert = importCert "$myname.pfx" "My" +$thumb = $nodecert.Thumbprint +# XXX guard this properly +winrm delete winrm/config/Listener?Address=*+Transport=HTTPS +winrm create winrm/config/Listener?Address=*+Transport=HTTPS "@{Hostname=`"$myname`";CertificateThumbprint=`"$thumb`"}" +$ingroup = net localgroup WinRMRemoteWMIUsers__ | Where-Object {$_ -eq $admin_username} +if($ingroup -ne $admin_username){ + net localgroup WinRMRemoteWMIUsers__ /add $admin_username +} + +$winrmcert = importCert "$myname-winrm.crt" "TrustedPeople" +Set-Item -Path WSMan:\localhost\Service\Auth\Certificate -Value $true +Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" -Name LocalAccountTokenFilterPolicy -Value 1 +if($creds){ + log "Enabling WinRM cert auth for $real_admin_user" + New-Item -Path WSMan:\localhost\ClientCertificate -Subject "$real_admin_user@localhost" -URI * -Issuer $muca.Thumbprint -Force -Credential $creds +} +winrm set winrm/config/winrs '@{MaxMemoryPerShellMB="8192"}' +winrm set winrm/config '@{MaxTimeoutms="1800000"}' +Restart-Service WinRm + +if ($need_reboot){ + log "- REBOOT -" + Restart-Computer -Force + exit +} + +if (!(Get-NetFirewallRule -DisplayName "Allow SSH" -ErrorAction SilentlyContinue)){ + log "Opening port 22 in Windows Firewall" + New-NetFirewallRule -DisplayName "Allow SSH" -Direction Inbound -LocalPort 22 -Protocol TCP -Action Allow +} +if (!(Get-NetFirewallRule -DisplayName "Allow WinRM SSL" -ErrorAction SilentlyContinue)){ + New-NetFirewallRule -DisplayName "Allow WinRM SSL" -Direction Inbound -LocalPort 5986 -Protocol TCP -Action Allow +} + +Add-Content c:/mu-installer-ran-updates "$(Get-Date -f MM-dd-yyyy_HH:mm:ss)" +callMomma "mu_bootstrap" +Set-Content "c:/mu_userdata_complete" "yup" +Remove-Item -Recurse $tmp +Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy Undefined + +true diff --git a/modules/mu/clouds/azure/vpc.rb b/modules/mu/clouds/azure/vpc.rb new file mode 100644 index 000000000..0c6ea4323 --- /dev/null +++ b/modules/mu/clouds/azure/vpc.rb @@ -0,0 +1,801 @@ +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module MU + class Cloud + class Azure + + # Creation of Virtual Private Clouds and associated artifacts (routes, subnets, etc). + class VPC < MU::Cloud::VPC + attr_reader :cloud_desc_cache + attr_reader :resource_group + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + @subnets = [] + @subnetcachesemaphore = Mutex.new + + if !mu_name.nil? + @mu_name = mu_name + if @cloud_id + cloud_desc + @cloud_id = Id.new(cloud_desc.id) + @resource_group ||= @cloud_id.resource_group + loadSubnets(use_cache: true) + end + elsif @config['scrub_mu_isms'] + @mu_name = @config['name'] + else + @mu_name = @deploy.getResourceName(@config['name']) + end + end + + # Called automatically by {MU::Deploy#createResources} + def create + create_update + end + + + # Called automatically by {MU::Deploy#createResources} + def groom + + if @config['peers'] + count = 0 + @config['peers'].each { |peer| + if peer['vpc']['name'] + peer_obj = @deploy.findLitterMate(name: peer['vpc']['name'], type: "vpcs", habitat: peer['vpc']['project']) + next if peer_obj.mu_name < @mu_name # both of us would try to create this peering, otherwise, so don't step on each other + else + tag_key, tag_value = peer['vpc']['tag'].split(/=/, 2) if !peer['vpc']['tag'].nil? + if peer['vpc']['deploy_id'].nil? and peer['vpc']['id'].nil? and tag_key.nil? + peer['vpc']['deploy_id'] = @deploy.deploy_id + end + + peer_obj = MU::MommaCat.findStray( + "Azure", + "vpcs", + deploy_id: peer['vpc']['deploy_id'], + cloud_id: peer['vpc']['id'], + name: peer['vpc']['name'], + tag_key: tag_key, + tag_value: tag_value, + dummy_ok: true + ).first + end + + raise MuError, "No result looking for #{@mu_name}'s peer VPCs (#{peer['vpc']})" if peer_obj.nil? + + ext_peerings = MU::Cloud::Azure.network(credentials: @credentials).virtual_network_peerings.list(@resource_group, @cloud_id) + peer_name = @mu_name+"-"+@config['name'].upcase+"-"+peer_obj.config['name'].upcase + peer_params = MU::Cloud::Azure.network(:VirtualNetworkPeering).new + peer_params.remote_virtual_network = peer_obj.cloud_desc + peer['allow_forwarded_traffic'] ||= false + peer_params.allow_forwarded_traffic = peer['allow_forwarded_traffic'] + peer['allow_gateway_traffic'] ||= false + peer_params.allow_gateway_transit = peer['allow_gateway_traffic'] + + need_update = true + exists = false + ext_peerings.each { |ext_peering| + if ext_peering.remote_virtual_network.id == peer_obj.cloud_desc.id + exists = true + need_update = (ext_peering.allow_forwarded_traffic != peer_params.allow_forwarded_traffic or ext_peering.allow_gateway_transit != peer_params.allow_gateway_transit) + end + } + + if need_update + if !exists + MU.log "Creating peering connection from #{@mu_name} to #{peer_obj.mu_name}", details: peer_params + else + MU.log "Updating peering connection from #{@mu_name} to #{peer_obj.mu_name}", MU::NOTICE, details: peer_params + end + MU::Cloud::Azure.network(credentials: @credentials).virtual_network_peerings.create_or_update(@resource_group, @cloud_id, peer_name, peer_params) + end + } + end + + create_update + end + + # Describe this VPC + # @return [Hash] + def notify + base = {} + base = MU.structToHash(cloud_desc) + base["cloud_id"] = @cloud_id.name + base.merge!(@config.to_h) + base + end +# + # Describe this VPC from the cloud platform's perspective + # @return [Hash] + def cloud_desc + if @cloud_desc_cache + return @cloud_desc_cache + end + @cloud_desc_cache = MU::Cloud::Azure::VPC.find(cloud_id: @cloud_id, resource_group: @resource_group).values.first + + @cloud_id ||= Id.new(@cloud_desc_cache.id) + @cloud_desc_cache + end + + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) + found = {} + + # Azure resources are namedspaced by resource group. If we weren't + # told one, we may have to search all the ones we can see. + resource_groups = if args[:resource_group] + [args[:resource_group]] + elsif args[:cloud_id] and args[:cloud_id].is_a?(MU::Cloud::Azure::Id) + [args[:cloud_id].resource_group] + else + MU::Cloud::Azure.resources(credentials: args[:credentials]).resource_groups.list.map { |rg| rg.name } + end + + if args[:cloud_id] + id_str = args[:cloud_id].is_a?(MU::Cloud::Azure::Id) ? args[:cloud_id].name : args[:cloud_id] + resource_groups.each { |rg| + resp = MU::Cloud::Azure.network(credentials: args[:credentials]).virtual_networks.get(rg, id_str) + + found[Id.new(resp.id)] = resp if resp + } + else + if args[:resource_group] + MU::Cloud::Azure.network(credentials: args[:credentials]).virtual_networks.list(args[:resource_group]).each { |net| + found[Id.new(net.id)] = net + } + else + MU::Cloud::Azure.network(credentials: args[:credentials]).virtual_networks.list_all.each { |net| + found[Id.new(net.id)] = net + } + end + end + + found + end + + # Return an array of MU::Cloud::Azure::VPC::Subnet objects describe the + # member subnets of this VPC. + # + # @return [Array] + def subnets + if @subnets.nil? or @subnets.size == 0 + return loadSubnets + end + return @subnets + end + + # Describe subnets associated with this VPC. We'll compose identifying + # information similar to what MU::Cloud.describe builds for first-class + # resources. + # @param use_cache [Boolean]: If available, use saved deployment metadata to describe subnets, instead of querying the cloud API + # @return [Array]: A list of cloud provider identifiers of subnets associated with this VPC. + def loadSubnets(use_cache: false) + desc = cloud_desc + @subnets = [] + + MU::Cloud::Azure.network(credentials: @credentials).subnets.list(@resource_group, cloud_desc.name).each { |subnet| + subnet_cfg = { + "cloud_id" => subnet.name, + "mu_name" => subnet.name, + "credentials" => @config['credentials'], + "region" => @config['region'], + "ip_block" => subnet.address_prefix + } + if @config['subnets'] + @config['subnets'].each { |s| + if s['ip_block'] == subnet_cfg['ip_block'] + subnet_cfg['name'] = s['name'] + break + end + } + end + subnet_cfg['name'] ||= subnet.name + @subnets << MU::Cloud::Azure::VPC::Subnet.new(self, subnet_cfg) + } + @subnets + end + + # Given some search criteria try locating a NAT Gaateway in this VPC. + # @param nat_cloud_id [String]: The cloud provider's identifier for this NAT. + # @param nat_filter_key [String]: A cloud provider filter to help identify the resource, used in conjunction with nat_filter_value. + # @param nat_filter_value [String]: A cloud provider filter to help identify the resource, used in conjunction with nat_filter_key. + # @param region [String]: The cloud provider region of the target instance. + def findNat(nat_cloud_id: nil, nat_filter_key: nil, nat_filter_value: nil, region: MU.curRegion) + nil + end + + # Given some search criteria for a {MU::Cloud::Server}, see if we can + # locate a NAT host in this VPC. + # @param nat_name [String]: The name of the resource as defined in its 'name' Basket of Kittens field, typically used in conjunction with deploy_id. + # @param nat_cloud_id [String]: The cloud provider's identifier for this NAT. + # @param nat_tag_key [String]: A cloud provider tag to help identify the resource, used in conjunction with tag_value. + # @param nat_tag_value [String]: A cloud provider tag to help identify the resource, used in conjunction with tag_key. + # @param nat_ip [String]: An IP address associated with the NAT instance. + def findBastion(nat_name: nil, nat_cloud_id: nil, nat_tag_key: nil, nat_tag_value: nil, nat_ip: nil) + [:nat_name, :nat_cloud_id, :nat_tag_key, :nat_tag_value, :nat_ip].each { |var| + if binding.local_variable_get(var) != nil + binding.local_variable_set(var, var.to_s) + end + + # If we're searching by name, assume it's part of this here deploy. + if nat_cloud_id.nil? and !@deploy.nil? + deploy_id = @deploy.deploy_id + end + found = MU::MommaCat.findStray( + "Azure", + "server", + name: nat_name, + cloud_id: nat_cloud_id, + deploy_id: deploy_id, + tag_key: nat_tag_key, + tag_value: nat_tag_value, + allow_multi: true, + dummy_ok: true, + calling_deploy: @deploy + ) + + return nil if found.nil? || found.empty? + if found.size == 1 + return found.first + end + + } + nil + end + + # Check for a subnet in this VPC matching one or more of the specified + # criteria, and return it if found. + def getSubnet(cloud_id: nil, name: nil, tag_key: nil, tag_value: nil, ip_block: nil) + loadSubnets + if !cloud_id.nil? and cloud_id.match(/^https:\/\//) + cloud_id.gsub!(/.*?\//, "") + end + MU.log "getSubnet(cloud_id: #{cloud_id}, name: #{name}, tag_key: #{tag_key}, tag_value: #{tag_value}, ip_block: #{ip_block})", MU::DEBUG, details: caller[0] + + @subnets.each { |subnet| + if !cloud_id.nil? and !subnet.cloud_id.nil? and subnet.cloud_id.to_s == cloud_id.to_s + return subnet + elsif !name.nil? and !subnet.name.nil? and subnet.name.to_s == name.to_s + return subnet + end + } + return nil + end + @route_cache = {} + @rtb_cache = {} + @rtb_cache_semaphore = Mutex.new + # Check whether we (the Mu Master) have a direct route to a particular + # instance. Useful for skipping hops through bastion hosts to get + # directly at child nodes in peered VPCs, the public internet, and the + # like. + # @param target_instance [OpenStruct]: The cloud descriptor of the instance to check. + # @param region [String]: The cloud provider region of the target subnet. + # @return [Boolean] + def self.haveRouteToInstance?(target_instance, region: MU.curRegion, credentials: nil) + +# target_instance.network_profile.network_interfaces.each { |iface| +# iface_id = Id.new(iface.is_a?(Hash) ? iface['id'] : iface.id) +# iface_desc = MU::Cloud::Azure.network(credentials: credentials).network_interfaces.get(iface_id.resource_group, iface_id.to_s) +# iface_desc.ip_configurations.each { |ipcfg| +# if ipcfg.respond_to?(:public_ipaddress) and ipcfg.public_ipaddress +# return true # XXX invalid if Mu can't talk to the internet +# end +# } +# } + + return false if MU.myCloud != "Azure" +# XXX if we're in Azure, see if this is in our VPC or if we're peered to its VPC + false + end + + + # Does this resource type exist as a global (cloud-wide) artifact, or + # is it localized to a region/zone? + # @return [Boolean] + def self.isGlobal? + false + end + + # Denote whether this resource implementation is experiment, ready for + # testing, or ready for production use. + def self.quality + MU::Cloud::BETA + end + + # Stub method. Azure resources are cleaned up by removing the parent + # resource group. + # @return [void] + def self.cleanup(**args) + end + + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + # XXX add flag to return the diff between @config and live cloud + def toKitten(rootparent: nil, billing: nil) + return nil if cloud_desc.name == "default" # parent project builds these + bok = { + "cloud" => "Azure", + "project" => @config['project'], + "credentials" => @config['credentials'] + } + + bok + end + + # Cloud-specific configuration properties. + # @param config [MU::Config]: The calling MU::Config object + # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource + def self.schema(config = nil) + toplevel_required = [] + schema = { + "peers" => { + "items" => { + "properties" => { + "allow_forwarded_traffic" => { + "type" => "boolean", + "default" => false, + "description" => "Allow traffic originating from outside peered networks" + }, + "allow_gateway_traffic" => { + "type" => "boolean", + "default" => false, + "description" => "Permit peered networks to use each others' VPN gateways" + } + } + } + } + } + [toplevel_required, schema] + end + + + # Cloud-specific pre-processing of {MU::Config::BasketofKittens::vpcs}, bare and unvalidated. + # @param vpc [Hash]: The resource to process and validate + # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member + # @return [Boolean]: True if validation succeeded, False otherwise + def self.validateConfig(vpc, configurator) + ok = true + vpc['region'] ||= MU::Cloud::Azure.myRegion(vpc['credentials']) + + if vpc['subnets'] + vpc['subnets'].each { |subnet| + subnet_routes[subnet['route_table']] = Array.new if subnet_routes[subnet['route_table']].nil? + subnet_routes[subnet['route_table']] << subnet['name'] + } + end + + if (!vpc['subnets'] or vpc['subnets'].empty?) and vpc['create_standard_subnets'] + subnets = configurator.divideNetwork(vpc['ip_block'], vpc['route_tables'].size, 28) + vpc['subnets'] ||= [] + vpc['route_tables'].each { |rtb| + is_public = false + rtb['routes'].each { |route| + if route['gateway'] == "#INTERNET" + is_public = true + break + end + } + vpc['subnets'] << { + "name" => "Subnet#{rtb['name'].capitalize}", + "is_public" => is_public, + "ip_block" => subnets.shift, + "route_table" => rtb['name'] + } + } + end + + vpc['route_tables'].each { |rtb| + rtb['routes'] ||= [] + rtb['routes'] << { "destination_network" => vpc['ip_block'] } + rtb['routes'].uniq! + } + + default_acl = { + "name" => vpc['name']+"-defaultfw", + "cloud" => "Azure", + "region" => vpc['region'], + "credentials" => vpc['credentials'], + "rules" => [ + { + "ingress" => true, "proto" => "all", "hosts" => [vpc['ip_block']] + }, + { + "egress" => true, "proto" => "all", "hosts" => [vpc['ip_block']] + } + ] + } + vpc["dependencies"] ||= [] + vpc["dependencies"] << { + "type" => "firewall_rule", + "name" => vpc['name']+"-defaultfw" + } + + if !configurator.insertKitten(default_acl, "firewall_rules", true) + ok = false + end + + ok + end + + # @param route [Hash]: A route description, per the Basket of Kittens schema + # @param server [MU::Cloud::Azure::Server]: Instance to which this route will apply + def createRouteForInstance(route, server) + createRoute(route, network: @url, tags: [MU::Cloud::Azure.nameStr(server.mu_name)]) + end + + private + + def create_update + @config = MU::Config.manxify(@config) + @config['region'] ||= MU::Cloud::Azure.myRegion(@config['credentials']) + tags = {} + if !@config['scrub_mu_isms'] + tags = MU::MommaCat.listStandardTags + end + if @config['tags'] + @config['tags'].each { |tag| + tags[tag['key']] = tag['value'] + } + end + + vpc_obj = MU::Cloud::Azure.network(:VirtualNetwork).new + addr_space_obj = MU::Cloud::Azure.network(:AddressSpace).new + addr_space_obj.address_prefixes = [ + @config['ip_block'] + ] + vpc_obj.address_space = addr_space_obj + vpc_obj.location = @config['region'] + vpc_obj.tags = tags + + my_fw = deploy.findLitterMate(type: "firewall_rule", name: @config['name']+"-defaultfw") + + @resource_group = @deploy.deploy_id+"-"+@config['region'].upcase + + need_apply = false + ext_vpc = nil + begin + ext_vpc = MU::Cloud::Azure.network(credentials: @config['credentials']).virtual_networks.get( + @resource_group, + @mu_name + ) + rescue ::MU::Cloud::Azure::APIError => e + if e.message.match(/: ResourceNotFound:/) + need_apply = true + else + raise e + end + end +# XXX raw update seems to destroy child resources; if we just need to update +# tags, do that with .update_tags + if !ext_vpc + MU.log "Creating VPC #{@mu_name} (#{@config['ip_block']}) in #{@config['region']}", details: vpc_obj + need_apply = true + elsif ext_vpc.location != vpc_obj.location or +# ext_vpc.tags != vpc_obj.tags or +# XXX updating tags is a different API call + ext_vpc.address_space.address_prefixes != vpc_obj.address_space.address_prefixes + MU.log "Updating VPC #{@mu_name} (#{@config['ip_block']}) in #{@config['region']}", MU::NOTICE, details: vpc_obj +MU.structToHash(ext_vpc).diff(MU.structToHash(vpc_obj)) + need_apply = true + end + + if need_apply + begin + resp = MU::Cloud::Azure.network(credentials: @config['credentials']).virtual_networks.create_or_update( + @resource_group, + @mu_name, + vpc_obj + ) + @cloud_id = Id.new(resp.id) + rescue ::MU::Cloud::Azure::APIError => e + if e.message.match(/InUseSubnetCannotBeDeleted: /) + MU.log "Cannot delete an in-use Azure subnet", MU::WARN + else + raise e + end + end + end + + # this is slow, so maybe thread it + rtb_map = {} + routethreads = [] + create_nat_gateway = false + @config['route_tables'].each { |rtb_cfg| + routethreads << Thread.new(rtb_cfg) { |rtb| + rtb_name = @mu_name+"-"+rtb['name'].upcase + rtb_obj = MU::Cloud::Azure.network(:RouteTable).new + rtb_obj.location = @config['region'] + + rtb_obj.tags = tags + rtb_ref_obj = MU::Cloud::Azure.network(:RouteTable).new + rtb_ref_obj.name = rtb_name + rtb_map[rtb['name']] = rtb_ref_obj + + need_apply = false + ext_rtb = nil + begin + ext_rtb = MU::Cloud::Azure.network(credentials: @config['credentials']).route_tables.get( + @resource_group, + rtb_name + ) + rtb_map[rtb['name']] = ext_rtb + rescue MU::Cloud::Azure::APIError => e + if e.message.match(/: ResourceNotFound:/) + need_apply = true + else + raise e + end + end + + if !ext_rtb + MU.log "Creating route table #{rtb_name} in VPC #{@mu_name}", details: rtb_obj + need_apply = true + elsif ext_rtb.location != rtb_obj.location or + ext_rtb.tags != rtb_obj.tags + need_apply = true + MU.log "Updating route table #{rtb_name} in VPC #{@mu_name}", MU::NOTICE, details: rtb_obj + end + + if need_apply + rtb_map[rtb['name']] = MU::Cloud::Azure.network(credentials: @config['credentials']).route_tables.create_or_update( + @resource_group, + rtb_name, + rtb_obj + ) + end + + rtb['routes'].each { |route| + route_obj = MU::Cloud::Azure.network(:Route).new + route_obj.address_prefix = route['destination_network'] + routename = rtb_name+"-"+route['destination_network'].gsub(/[^a-z0-9]/i, "_") + route_obj.next_hop_type = if route['gateway'] == "#NAT" and @config['bastion'] + routename = rtb_name+"-NAT" + bastion_ref = MU::Config::Ref.get(@config['bastion']) + if bastion_ref.kitten and bastion_ref.kitten.cloud_desc + iface_id = Id.new(bastion_ref.kitten.cloud_desc.network_profile.network_interfaces.first.id) + iface_desc = MU::Cloud::Azure.network(credentials: @credentials).network_interfaces.get(@resource_group, iface_id.name) + if iface_desc and iface_desc.ip_configurations and iface_desc.ip_configurations.size > 0 + route_obj.next_hop_ip_address = iface_desc.ip_configurations.first.private_ipaddress + "VirtualAppliance" + else + "VnetLocal" + end + else + "VnetLocal" + end +# create_nat_gateway = true + elsif route['gateway'] == "#INTERNET" + routename = rtb_name+"-INTERNET" + "Internet" + else + routename = rtb_name+"-LOCAL" + "VnetLocal" + end +#next_hop_type 'VirtualNetworkGateway' is for VPNs I think + + need_apply = false + ext_route = nil + begin + ext_route = MU::Cloud::Azure.network(credentials: @config['credentials']).routes.get( + @resource_group, + rtb_name, + routename + ) + rescue MU::Cloud::Azure::APIError => e + if e.message.match(/\bNotFound\b/) + need_apply = true + else + raise e + end + end + + if !ext_route + MU.log "Creating route #{routename} for #{route['destination_network']} in route table #{rtb_name}", details: rtb_obj + need_apply = true + elsif ext_route.next_hop_type != route_obj.next_hop_type or + ext_route.address_prefix != route_obj.address_prefix + MU.log "Updating route #{routename} for #{route['destination_network']} in route table #{rtb_name}", MU::NOTICE, details: [route_obj, ext_route] + need_apply = true + end + + if need_apply + MU::Cloud::Azure.network(credentials: @config['credentials']).routes.create_or_update( + @resource_group, + rtb_name, + routename, + route_obj + ) + end + } + } + } + + routethreads.each { |t| + t.join + } + +# TODO this is only available in westus as of 2019-09-29 +# if create_nat_gateway +# nat_obj = MU::Cloud::Azure.network(:NatGateway).new +# nat_obj.location = @config['region'] +# nat_obj.tags = tags +# MU.log "Creating NAT Gateway #{@mu_name}-NAT", details: nat_obj +# MU::Cloud::Azure.network(credentials: @config['credentials']).nat_gateways.create_or_update( +# @resource_group, +# @mu_name+"-NAT", +# nat_obj +# ) +# end + + if @config['subnets'] + subnetthreads = [] + @config['subnets'].each { |subnet_cfg| + subnetthreads << Thread.new(subnet_cfg) { |subnet| + subnet_obj = MU::Cloud::Azure.network(:Subnet).new + subnet_name = @mu_name+"-"+subnet['name'].upcase + subnet_obj.address_prefix = subnet['ip_block'] + subnet_obj.route_table = rtb_map[subnet['route_table']] + if my_fw and my_fw.cloud_desc + subnet_obj.network_security_group = my_fw.cloud_desc + end + + need_apply = false + ext_subnet = nil + begin + ext_subnet = MU::Cloud::Azure.network(credentials: @config['credentials']).subnets.get( + @resource_group, + @cloud_id.to_s, + subnet_name + ) + rescue APIError => e + if e.message.match(/\bNotFound\b/) + need_apply = true + else +# raise e + end + end + + if !ext_subnet + MU.log "Creating Subnet #{subnet_name} in VPC #{@mu_name}", details: subnet_obj + need_apply = true + elsif (!ext_subnet.route_table.nil? and !subnet_obj.route_table.nil? and ext_subnet.route_table.id != subnet_obj.route_table.id) or + ext_subnet.address_prefix != subnet_obj.address_prefix or + ext_subnet.network_security_group.nil? and !subnet_obj.network_security_group.nil? or + (!ext_subnet.network_security_group.nil? and !subnet_obj.network_security_group.nil? and ext_subnet.network_security_group.id != subnet_obj.network_security_group.id) + MU.log "Updating Subnet #{subnet_name} in VPC #{@mu_name}", MU::NOTICE, details: subnet_obj +MU.structToHash(ext_subnet).diff(MU.structToHash(subnet_obj)) + need_apply = true + + end + + if need_apply + begin + MU::Cloud::Azure.network(credentials: @config['credentials']).subnets.create_or_update( + @resource_group, + @cloud_id.to_s, + subnet_name, + subnet_obj + ) + rescue ::MU::Cloud::Azure::APIError => e + if e.message.match(/InUseSubnetCannotBeUpdated: /) + MU.log "Cannot alter an in-use Azure subnet", MU::WARN + else + raise e + end + end + end + } + } + + subnetthreads.each { |t| + t.join + } + end + + loadSubnets + end + + protected + + # Subnets are almost a first-class resource. So let's kinda sorta treat + # them like one. This should only be invoked on objects that already + # exists in the cloud layer. + class Subnet < MU::Cloud::Azure::VPC + + attr_reader :cloud_id + attr_reader :id + attr_reader :ip_block + attr_reader :mu_name + attr_reader :name + attr_reader :cloud_desc_cache + attr_reader :resource_group + attr_reader :az + + # @param parent [MU::Cloud::Azure::VPC]: The parent VPC of this subnet. + # @param config [Hash]: + def initialize(parent, config, precache_description: true) + @parent = parent + @deploy = parent.deploy + @config = MU::Config.manxify(config) + @cloud_id = config['cloud_id'] + @mu_name = config['mu_name'] + @name = config['name'] + @deploydata = config # This is a dummy for the sake of describe() + @ip_block = config['ip_block'] + @cloud_desc_cache = nil + @az = parent.config['region'] + cloud_desc if precache_description + end + + # Return the cloud identifier for the default route of this subnet. + def defaultRoute + if cloud_desc and cloud_desc.route_table + rtb_id = MU::Cloud::Azure::Id.new(cloud_desc.route_table.id) + routes = MU::Cloud::Azure.network(credentials: @config['credentials']).routes.list( + rtb_id.resource_group, + rtb_id.name + ) + routes.each { |route| + return route if route.address_prefix == "0.0.0.0/0" + } + end + nil + end + + # Describe this VPC Subnet + # @return [Hash] + def notify + MU.structToHash(cloud_desc) + end + + # Describe this VPC Subnet from the cloud platform's perspective + def cloud_desc + return @cloud_desc_cache if !@cloud_desc_cache.nil? + @cloud_desc_cache = MU::Cloud::Azure.network(credentials: @parent.credentials).subnets.get(@parent.resource_group, @parent.cloud_desc.name, @cloud_id.to_s) + @cloud_desc_cache + end + + # Is this subnet privately-routable only, or public? + # @return [Boolean] + def private? + if cloud_desc and cloud_desc.route_table + rtb_id = MU::Cloud::Azure::Id.new(cloud_desc.route_table.id) + routes = MU::Cloud::Azure.network(credentials: @config['credentials']).routes.list( + rtb_id.resource_group, + rtb_id.name + ) + routes.each { |route| + return false if route.next_hop_type == "Internet" + } + true + end + end + end + + end #class + end #class + end +end #module diff --git a/modules/mu/clouds/cloudformation.rb b/modules/mu/clouds/cloudformation.rb index a56e042c2..bb52f007f 100644 --- a/modules/mu/clouds/cloudformation.rb +++ b/modules/mu/clouds/cloudformation.rb @@ -28,6 +28,15 @@ def self.required_instance_methods @@cloudformation_mode = false + # Return what we think of as a cloud object's habitat. In AWS, this means + # the +account_number+ in which it's resident. If this is not applicable, + # such as for a {Habitat} or {Folder}, returns nil. + # @param cloudobj [MU::Cloud::AWS]: The resource from which to extract the habitat id + # @return [String,nil] + def self.habitat(cloudobj) + cloudobj.respond_to?(:account_number) ? cloudobj.account_number : nil + end + # Toggle ourselves into a mode that will emit a CloudFormation template # instead of actual infrastructure. # @param set [Boolean]: Set the mode @@ -78,8 +87,8 @@ def self.listRegions(us_only = false, credentials: nil) # Stub method- there's no such thing as being "hosted" in a CloudFormation # environment. Calls {MU::Cloud::AWS.myRegion} to return sensible # values, if we happen to have AWS credentials configured. - def self.myRegion - MU::Cloud::AWS.myRegion + def self.myRegion(credentials = nil) + MU::Cloud::AWS.myRegion(credentials) end # Stub method- there's no such thing as being "hosted" in a CloudFormation @@ -259,12 +268,6 @@ def self.cloudFormationBase(type, cloudobj = nil, name: nil, tags: [], scrub_mu_ "Properties" => { } } - when "loggroup" - desc = { - "Type" => "AWS::EC2::LogGroup", - "Properties" => { - } - } when "cache_subnets" desc = { "Type" => "AWS::ElastiCache::SubnetGroup", @@ -649,7 +652,7 @@ def self.writeCloudFormationTemplate(tails: MU::Config.tails, config: {}, path: child_name = resource['#MUOBJECT'].cloudobj.cfm_name child_params = child_template[child_name]["Properties"]["Parameters"] child_params = Hash.new if child_params.nil? - cfm_template["Parameters"].each { |key, data| + cfm_template["Parameters"].keys.each { |key| child_params[key] = { "Ref" => key } } MU::Cloud::CloudFormation.setCloudFormationProp(child_template[child_name], "Parameters", child_params) diff --git a/modules/mu/clouds/cloudformation/firewall_rule.rb b/modules/mu/clouds/cloudformation/firewall_rule.rb index 8e1fc12ea..94ad98fc6 100644 --- a/modules/mu/clouds/cloudformation/firewall_rule.rb +++ b/modules/mu/clouds/cloudformation/firewall_rule.rb @@ -94,10 +94,10 @@ def notify # @param port_range [String]: A port range descriptor (e.g. 0-65535). Only valid with udp or tcp. # @return [void] def addRule(hosts, - proto: proto = "tcp", - port: port = nil, - egress: egress = false, - port_range: port_range = "0-65535" + proto: "tcp", + port: nil, + egress: false, + port_range: "0-65535" ) rule = Hash.new rule["proto"] = proto @@ -146,7 +146,7 @@ def self.validateConfig(acl, config) # Manufacture an EC2 security group. The second parameter, rules, is an # "ingress_rules" structure parsed and validated by MU::Config. ######################################################################### - def setRules(rules, add_to_self: add_to_self = false, ingress: ingress = true, egress: egress = false) + def setRules(rules, add_to_self: false, ingress: true, egress: false) return if rules.nil? or rules.size == 0 if add_to_self @@ -294,14 +294,6 @@ def self.schema(config) MU::Cloud::AWS::FirewallRule.schema(config) end - # Cloud-specific pre-processing of {MU::Config::BasketofKittens::servers}, bare and unvalidated. - # @param server [Hash]: The resource to process and validate - # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member - # @return [Boolean]: True if validation succeeded, False otherwise - def self.validateConfig(server, configurator) - MU::Cloud::AWS::FirewallRule.validateConfig(server, configurator) - end - # Does this resource type exist as a global (cloud-wide) artifact, or # is it localized to a region/zone? # @return [Boolean] diff --git a/modules/mu/clouds/cloudformation/server.rb b/modules/mu/clouds/cloudformation/server.rb index bcc469a37..578c13ddc 100644 --- a/modules/mu/clouds/cloudformation/server.rb +++ b/modules/mu/clouds/cloudformation/server.rb @@ -45,6 +45,7 @@ def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) "muID" => MU.deploy_id, "muUser" => MU.chef_user, "publicIP" => MU.mu_public_ip, + "mommaCatPort" => MU.mommaCatPort, "skipApplyUpdates" => @config['skipinitialupdates'], "windowsAdminName" => @config['windows_admin_username'], "resourceName" => @config["name"], @@ -303,7 +304,7 @@ def self.createIAMProfile(rolename, base_profile: nil, extra_policies: nil, clou role_name: baserole.role_name, policy_name: name ) - policies[name] = URI.unescape(resp.policy_document) + policies[name] = URI.decode(resp.policy_document) } } end @@ -340,6 +341,14 @@ def self.cleanup(*args) nil end + # Return the date/time a machine image was created. + # @param ami_id [String]: AMI identifier of an Amazon Machine Image + # @param credentials [String] + # @return [DateTime] + def self.imageTimeStamp(ami_id, credentials: nil, region: nil) + MU::Cloud::AWS.imageTimeStamp(ami_id, credentials: credentials, region: region) + end + # Cloud-specific configuration properties. # @param config [MU::Config]: The calling MU::Config object # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource diff --git a/modules/mu/clouds/cloudformation/server_pool.rb b/modules/mu/clouds/cloudformation/server_pool.rb index 054d9d909..a272b713a 100644 --- a/modules/mu/clouds/cloudformation/server_pool.rb +++ b/modules/mu/clouds/cloudformation/server_pool.rb @@ -166,6 +166,7 @@ def create "deploySSHKey" => @deploy.ssh_public_key, "muID" => MU.deploy_id, "muUser" => MU.chef_user, + "mommaCatPort" => MU.mommaCatPort, "publicIP" => MU.mu_public_ip, "skipApplyUpdates" => @config['skipinitialupdates'], "windowsAdminName" => @config['windows_admin_username'], diff --git a/modules/mu/clouds/cloudformation/vpc.rb b/modules/mu/clouds/cloudformation/vpc.rb index 5bb486e7d..6674aeac8 100644 --- a/modules/mu/clouds/cloudformation/vpc.rb +++ b/modules/mu/clouds/cloudformation/vpc.rb @@ -253,8 +253,6 @@ class Subnet < MU::Cloud::CloudFormation::VPC attr_reader :name attr_reader :cfm_template attr_reader :cfm_name - attr_reader :name - # @param parent [MU::Cloud::CloudFormation::VPC]: The parent VPC of this subnet. # @param config [Hash]: diff --git a/modules/mu/clouds/google.rb b/modules/mu/clouds/google.rb index b4126e65f..099132a80 100644 --- a/modules/mu/clouds/google.rb +++ b/modules/mu/clouds/google.rb @@ -29,13 +29,61 @@ class Google @@authorizers = {} @@acct_to_profile_map = {} @@enable_semaphores = {} + @@readonly_semaphore = Mutex.new + @@readonly = {} + + # Module used by {MU::Cloud} to insert additional instance methods into + # instantiated resources in this cloud layer. + module AdditionalResourceMethods + # Google Cloud url attribute, found in some form on most GCP cloud + # resources. + # @return [String] + def url + desc = cloud_desc + (desc and desc.self_link) ? desc.self_link : nil + end + end # Any cloud-specific instance methods we require our resource # implementations to have, above and beyond the ones specified by # {MU::Cloud} # @return [Array] def self.required_instance_methods - [] + [:url] + end + + # A hook that is always called just before any of the instance method of + # our resource implementations gets invoked, so that we can ensure that + # repetitive setup tasks (like resolving +:resource_group+ for Azure + # resources) have always been done. + # @param cloudobj [MU::Cloud] + # @param deploy [MU::MommaCat] + def self.resourceInitHook(cloudobj, deploy) + class << self + attr_reader :project_id + attr_reader :customer + # url is too complex for an attribute (we get it from the cloud API), + # so it's up in AdditionalResourceMethods instead + end + return if !cloudobj + + cloudobj.instance_variable_set(:@customer, MU::Cloud::Google.customerID(cloudobj.config['credentials'])) + +# XXX ensure @cloud_id and @project_id if this is a habitat +# XXX skip project_id if this is a folder or group + if deploy +# XXX this may be wrong for new deploys (but def right for regrooms) + project = MU::Cloud::Google.projectLookup(cloudobj.config['project'], deploy, sibling_only: true, raise_on_fail: false) + project_id = project.nil? ? cloudobj.config['project'] : project.cloudobj.cloud_id + cloudobj.instance_variable_set(:@project_id, project_id) + else + cloudobj.instance_variable_set(:@project_id, cloudobj.config['project']) + end + +# XXX @url? Well we're not likely to have @cloud_desc at this point, so maybe +# that needs to be a generic-to-google wrapper like def url; cloud_desc.self_link;end + +# XXX something like: vpc["habitat"] = MU::Cloud::Google.projectToRef(vpc["project"], config: configurator, credentials: vpc["credentials"]) end # If we're running this cloud, return the $MU_CFG blob we'd use to @@ -62,6 +110,17 @@ def self.config_example sample end + # If we reside in this cloud, return the VPC in which we, the Mu Master, reside. + # @return [MU::Cloud::VPC] + def self.myVPCObj + return nil if !hosted? + instance = MU.myCloudDescriptor + return nil if !instance or !instance.network_interfaces or instance.network_interfaces.size == 0 + vpc = MU::MommaCat.findStray("Google", "vpc", cloud_id: instance.network_interfaces.first.network.gsub(/.*?\/([^\/]+)$/, '\1'), dummy_ok: true, habitats: [myProject]) + return nil if vpc.nil? or vpc.size == 0 + vpc.first + end + # Return the name strings of all known sets of credentials for this cloud # @return [Array] def self.listCredentials @@ -72,6 +131,88 @@ def self.listCredentials $MU_CFG['google'].keys end + @@habmap = {} + + # Return what we think of as a cloud object's habitat. In GCP, this means + # the +project_id+ in which is resident. If this is not applicable, such + # as for a {Habitat} or {Folder}, returns nil. + # @param cloudobj [MU::Cloud::Google]: The resource from which to extract the habitat id + # @return [String,nil] + def self.habitat(cloudobj, nolookup: false, deploy: nil) + @@habmap ||= {} +# XXX whaddabout config['habitat'] HNNNGH + + return nil if !cloudobj.cloudclass.canLiveIn.include?(:Habitat) + +# XXX users are assholes because they're valid two different ways ugh ugh + return nil if [MU::Cloud::Google::Group, MU::Cloud::Google::Folder].include?(cloudobj.cloudclass) + if cloudobj.config and cloudobj.config['project'] + if nolookup + return cloudobj.config['project'] + end + if @@habmap[cloudobj.config['project']] + return @@habmap[cloudobj.config['project']] + end + deploy ||= cloudobj.deploy if cloudobj.respond_to?(:deploy) + + projectobj = projectLookup(cloudobj.config['project'], deploy, raise_on_fail: false) + + if projectobj + @@habmap[cloudobj.config['project']] = projectobj.cloud_id + return projectobj.cloud_id + end + end + + # blow up if this resource *has* to live in a project + if cloudobj.cloudclass.canLiveIn == [:Habitat] + MU.log "Failed to find project for cloudobj of class #{cloudobj.cloudclass.class.name}", MU::ERR, details: cloudobj + raise MuError, "Failed to find project for cloudobj of class #{cloudobj.cloudclass.class.name}" + end + + nil + end + + # Take a plain string that might be a reference to sibling project + # declared elsewhere in the active stack, or the project id of a live + # cloud resource, and return a {MU::Config::Ref} object + # @param project [String]: The name of a sibling project, or project id of an active project in GCP + # @param config [MU::Config]: A {MU::Config} object containing sibling resources, typically what we'd pass if we're calling during configuration parsing + # @param credentials [String]: + # @return [MU::Config::Ref] + def self.projectToRef(project, config: nil, credentials: nil) + return nil if !project + + if config and config.haveLitterMate?(project, "habitat") + ref = MU::Config::Ref.new( + name: project, + cloud: "Google", + credentials: credentials, + type: "habitats" + ) + end + + if !ref + resp = MU::MommaCat.findStray( + "Google", + "habitats", + cloud_id: project, + credentials: credentials, + dummy_ok: true + ) + if resp and resp.size > 0 + project_obj = resp.first + ref = MU::Config::Ref.new( + id: project_obj.cloud_id, + cloud: "Google", + credentials: credentials, + type: "habitats" + ) + end + end + + ref + end + # A shortcut for {MU::MommaCat.findStray} to resolve a shorthand project # name into a cloud object, whether it refers to a sibling by internal # name or by cloud identifier. @@ -79,20 +220,21 @@ def self.listCredentials # @param deploy [String] # @param raise_on_fail [Boolean] # @param sibling_only [Boolean] - # @return [MU::Cloud::Habitat,nil] + # @return [MU::Config::Habitat,nil] def self.projectLookup(name, deploy = MU.mommacat, raise_on_fail: true, sibling_only: false) - project_obj = deploy.findLitterMate(type: "habitats", name: name) + project_obj = deploy.findLitterMate(type: "habitats", name: name) if deploy if !project_obj and !sibling_only resp = MU::MommaCat.findStray( "Google", "habitats", - deploy_id: deploy.deploy_id, + deploy_id: deploy ? deploy.deploy_id : nil, cloud_id: name, name: name, dummy_ok: true ) - project_obj = resp.first if resp + + project_obj = resp.first if resp and resp.size > 0 end if (!project_obj or !project_obj.cloud_id) and raise_on_fail @@ -109,6 +251,9 @@ def self.projectLookup(name, deploy = MU.mommacat, raise_on_fail: true, sibling_ def self.adminBucketName(credentials = nil) #XXX find a default if this particular account doesn't have a log_bucket_name configured cfg = credConfig(credentials) + if cfg.nil? + raise MuError, "Failed to load Google credential set #{credentials}" + end cfg['log_bucket_name'] end @@ -124,7 +269,7 @@ def self.adminBucketUrl(credentials = nil) # credentials. If no account name is specified, will return one flagged as # default. Returns nil if GCP is not configured. Throws an exception if # an account name is specified which does not exist. - # @param name [String]: The name of the key under 'aws' in mu.yaml to return + # @param name [String]: The name of the key under 'google' in mu.yaml to return # @return [Hash,nil] def self.credConfig(name = nil, name_only: false) # If there's nothing in mu.yaml (which is wrong), but we're running @@ -134,23 +279,17 @@ def self.credConfig(name = nil, name_only: false) return @@my_hosted_cfg if @@my_hosted_cfg if hosted? - begin -# iam_data = JSON.parse(getAWSMetaData("iam/info")) -# if iam_data["InstanceProfileArn"] and !iam_data["InstanceProfileArn"].empty? - @@my_hosted_cfg = hosted_config - return name_only ? "#default" : @@my_hosted_cfg -# end - rescue JSON::ParserError => e - end + @@my_hosted_cfg = hosted_config + return name_only ? "#default" : @@my_hosted_cfg end return nil end if name.nil? - $MU_CFG['google'].each_pair { |name, cfg| + $MU_CFG['google'].each_pair { |set, cfg| if cfg['default'] - return name_only ? name : cfg + return name_only ? set : cfg end } else @@ -173,10 +312,24 @@ def self.myRegion(credentials = nil) elsif MU::Cloud::Google.hosted? zone = MU::Cloud::Google.getGoogleMetaData("instance/zone") @@myRegion_var = zone.gsub(/^.*?\/|\-\d+$/, "") + else + @@myRegion_var = "us-east4" end @@myRegion_var end + # Do cloud-specific deploy instantiation tasks, such as copying SSH keys + # around, sticking secrets in buckets, creating resource groups, etc + # @param deploy [MU::MommaCat] + def self.initDeploy(deploy) + end + + # Purge cloud-specific deploy meta-artifacts (SSH keys, resource groups, + # etc) + # @param deploy_id [MU::MommaCat] + def self.cleanDeploy(deploy_id, credentials: nil, noop: false) + end + # Plant a Mu deploy secret into a storage bucket somewhere for so our kittens can consume it # @param deploy_id [String]: The deploy for which we're writing the secret # @param value [String]: The contents of the secret @@ -199,8 +352,7 @@ def self.writeDeploySecret(deploy_id, value, name = nil, credentials: nil) ) f.unlink rescue ::Google::Apis::ClientError => e -# XXX comment for NCBI tests -# raise MU::MommaCat::DeployInitializeError, "Got #{e.inspect} trying to write #{name} to #{adminBucketName(credentials)}" + raise MU::MommaCat::DeployInitializeError, "Got #{e.inspect} trying to write #{name} to #{adminBucketName(credentials)}" end end @@ -250,9 +402,10 @@ def self.grantDeploySecretAccess(acct, deploy_id = MU.deploy_id, name = nil, cre ) } rescue ::Google::Apis::ClientError => e +MU.log e.message, MU::WARN, details: e.inspect if e.inspect.match(/body: "Not Found"/) raise MuError, "Google admin bucket #{adminBucketName(credentials)} or key #{name} does not appear to exist or is not visible with #{credentials ? credentials : "default"} credentials" - elsif e.inspect.match(/notFound: No such object:/) + elsif e.message.match(/notFound: |Unknown user:/) if retries < 5 sleep 5 retries += 1 @@ -265,7 +418,7 @@ def self.grantDeploySecretAccess(acct, deploy_id = MU.deploy_id, name = nil, cre sleep 10 retry else - raise MuError, "Got #{e.inspect} trying to set ACLs for #{deploy_id} in #{adminBucketName(credentials)}" + raise MuError, "Got #{e.message} trying to set ACLs for #{deploy_id} in #{adminBucketName(credentials)}" end end end @@ -289,7 +442,7 @@ def self.hosted? return @@is_in_gcp end - if getGoogleMetaData("instance/name") + if getGoogleMetaData("project/project-id") @@is_in_gcp = true return true end @@ -323,11 +476,11 @@ def self.getGoogleMetaData(param) # @param name [String]: A resource name for the certificate # @param cert [String,OpenSSL::X509::Certificate]: An x509 certificate # @param key [String,OpenSSL::PKey]: An x509 private key - # @return [Google::Apis::ComputeBeta::SslCertificate] + # @return [Google::Apis::ComputeV1::SslCertificate] def self.createSSLCertificate(name, cert, key, flags = {}, credentials: nil) flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) flags["description"] ||= MU.deploy_id - certobj = ::Google::Apis::ComputeBeta::SslCertificate.new( + certobj = ::Google::Apis::ComputeV1::SslCertificate.new( name: name, certificate: cert.to_s, private_key: key.to_s, @@ -354,27 +507,40 @@ def self.loadCredentials(scopes = nil, credentials: nil) cfg = credConfig(credentials) - if cfg['project'] - @@enable_semaphores[cfg['project']] ||= Mutex.new - end - if cfg + if cfg['project'] + @@enable_semaphores[cfg['project']] ||= Mutex.new + end data = nil @@authorizers[credentials] ||= {} - def self.get_machine_credentials(scopes) + def self.get_machine_credentials(scopes, credentials = nil) @@svc_account_name = MU::Cloud::Google.getGoogleMetaData("instance/service-accounts/default/email") MU.log "We are hosted in GCP, so I will attempt to use the service account #{@@svc_account_name} to make API requests.", MU::DEBUG @@authorizers[credentials][scopes.to_s] = ::Google::Auth.get_application_default(scopes) @@authorizers[credentials][scopes.to_s].fetch_access_token! @@default_project ||= MU::Cloud::Google.getGoogleMetaData("project/project-id") + begin + listRegions(credentials: credentials) + listInstanceTypes(credentials: credentials) + listProjects(credentials) + rescue ::Google::Apis::ClientError => e + MU.log "Found machine credentials #{@@svc_account_name}, but these don't appear to have sufficient permissions or scopes", MU::WARN, details: scopes + @@authorizers.delete(credentials) + return nil + end @@authorizers[credentials][scopes.to_s] end - if cfg["credentials_file"] + if cfg["credentials_file"] or cfg["credentials_encoded"] + begin - data = JSON.parse(File.read(cfg["credentials_file"])) + data = if cfg["credentials_encoded"] + JSON.parse(Base64.decode64(cfg["credentials_encoded"])) + else + JSON.parse(File.read(cfg["credentials_file"])) + end @@default_project ||= data["project_id"] creds = { :json_key_io => StringIO.new(MultiJson.dump(data)), @@ -388,18 +554,20 @@ def self.get_machine_credentials(scopes) raise MuError, "Google Cloud credentials file #{cfg["credentials_file"]} is missing or invalid (#{e.message})" end MU.log "Google Cloud credentials file #{cfg["credentials_file"]} is missing or invalid", MU::WARN, details: e.message - return get_machine_credentials(scopes) + return get_machine_credentials(scopes, credentials) end elsif cfg["credentials"] begin vault, item = cfg["credentials"].split(/:/) data = MU::Groomer::Chef.getSecret(vault: vault, item: item).to_h - rescue MU::Groomer::Chef::MuNoSuchSecret + rescue MU::Groomer::MuNoSuchSecret if !MU::Cloud::Google.hosted? raise MuError, "Google Cloud credentials not found in Vault #{vault}:#{item}" end MU.log "Google Cloud credentials not found in Vault #{vault}:#{item}", MU::WARN - return get_machine_credentials(scopes) + found = get_machine_credentials(scopes, credentials) + raise MuError, "No valid credentials available! Either grant admin privileges to machine service account, or manually add a different one with mu-configure" if found.nil? + return found end @@default_project ||= data["project_id"] @@ -411,7 +579,9 @@ def self.get_machine_credentials(scopes) @@authorizers[credentials][scopes.to_s] = ::Google::Auth::ServiceAccountCredentials.make_creds(creds) return @@authorizers[credentials][scopes.to_s] elsif MU::Cloud::Google.hosted? - return get_machine_credentials(scopes) + found = get_machine_credentials(scopes, credentials) + raise MuError, "No valid credentials available! Either grant admin privileges to machine service account, or manually add a different one with mu-configure" if found.nil? + return found else raise MuError, "Google Cloud credentials not configured" end @@ -455,15 +625,39 @@ def self.myServiceAccount end end + @@default_project_cache = {} + # Our credentials map to a project, an organizational structure in Google # Cloud. This fetches the identifier of the project associated with our # default credentials. # @param credentials [String] # @return [String] def self.defaultProject(credentials = nil) + if @@default_project_cache.has_key?(credentials) + return @@default_project_cache[credentials] + end cfg = credConfig(credentials) - return myProject if !cfg or !cfg['project'] + if !cfg or !cfg['project'] + if hosted? + @@default_project_cache[credentials] = myProject + return myProject + end + if cfg + begin + result = MU::Cloud::Google.resource_manager(credentials: credentials).list_projects + result.projects.reject! { |p| p.lifecycle_state == "DELETE_REQUESTED" } + available = result.projects.map { |p| p.project_id } + if available.size == 1 + @@default_project_cache[credentials] = available[0] + return available[0] + end + rescue # fine + end + end + end + return nil if !cfg loadCredentials(credentials) if !@@authorizers[credentials] + @@default_project_cache[credentials] = cfg['project'] cfg['project'] end @@ -486,7 +680,7 @@ def self.defaultFolder(credentials = nil) def self.listProjects(credentials = nil) cfg = credConfig(credentials) return [] if !cfg or !cfg['project'] - result = MU::Cloud::Google.resource_manager.list_projects + result = MU::Cloud::Google.resource_manager(credentials: credentials).list_projects result.projects.reject! { |p| p.lifecycle_state == "DELETE_REQUESTED" } result.projects.map { |p| p.project_id } end @@ -530,23 +724,28 @@ def self.listRegions(us_only = false, credentials: nil) # "translate" machine types across cloud providers. # @param region [String]: Supported machine types can vary from region to region, so we look for the set we're interested in specifically # @return [Hash] - def self.listInstanceTypes(region = myRegion) - return @@instance_types if @@instance_types and @@instance_types[region] - if !MU::Cloud::Google.defaultProject - return {} + def self.listInstanceTypes(region = self.myRegion, credentials: nil, project: MU::Cloud::Google.defaultProject) + return {} if !credConfig(credentials) + if @@instance_types and + @@instance_types[project] and + @@instance_types[project][region] + return @@instance_types end + return {} if !project + @@instance_types ||= {} - @@instance_types[region] ||= {} - result = MU::Cloud::Google.compute.list_machine_types(MU::Cloud::Google.defaultProject, listAZs(region).first) + @@instance_types[project] ||= {} + @@instance_types[project][region] ||= {} + result = MU::Cloud::Google.compute(credentials: credentials).list_machine_types(project, listAZs(region).first) result.items.each { |type| - @@instance_types[region][type.name] ||= {} - @@instance_types[region][type.name]["memory"] = sprintf("%.1f", type.memory_mb/1024.0).to_f - @@instance_types[region][type.name]["vcpu"] = type.guest_cpus.to_f + @@instance_types[project][region][type.name] ||= {} + @@instance_types[project][region][type.name]["memory"] = sprintf("%.1f", type.memory_mb/1024.0).to_f + @@instance_types[project][region][type.name]["vcpu"] = type.guest_cpus.to_f if type.is_shared_cpu - @@instance_types[region][type.name]["ecu"] = "Variable" + @@instance_types[project][region][type.name]["ecu"] = "Variable" else - @@instance_types[region][type.name]["ecu"] = type.guest_cpus + @@instance_types[project][region][type.name]["ecu"] = type.guest_cpus end } @@instance_types @@ -563,22 +762,26 @@ def self.nameStr(name) # server resides (if it resides in this cloud provider's ecosystem). # @param region [String]: The region to search. # @return [Array]: The Availability Zones in this region. - def self.listAZs(region = MU.curRegion) + def self.listAZs(region = self.myRegion) + return [] if !credConfig MU::Cloud::Google.listRegions if !@@regions.has_key?(region) - raise MuError, "No such Google Cloud region '#{region}'" if !@@regions.has_key?(region) + if !@@regions.has_key?(region) + MU.log "Failed to get GCP region #{region}", MU::ERR, details: @@regions + raise MuError, "No such Google Cloud region '#{region}'" if !@@regions.has_key?(region) + end @@regions[region] end # Google's Compute Service API - # @param subclass []: If specified, will return the class ::Google::Apis::ComputeBeta::subclass instead of an API client instance + # @param subclass []: If specified, will return the class ::Google::Apis::ComputeV1::subclass instead of an API client instance def self.compute(subclass = nil, credentials: nil) - require 'google/apis/compute_beta' + require 'google/apis/compute_v1' if subclass.nil? - @@compute_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "ComputeBeta::ComputeService", scopes: ['https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/compute.readonly'], credentials: credentials) + @@compute_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "ComputeV1::ComputeService", scopes: ['cloud-platform', 'compute.readonly'], credentials: credentials) return @@compute_api[credentials] elsif subclass.is_a?(Symbol) - return Object.const_get("::Google").const_get("Apis").const_get("ComputeBeta").const_get(subclass) + return Object.const_get("::Google").const_get("Apis").const_get("ComputeV1").const_get(subclass) end end @@ -588,7 +791,7 @@ def self.storage(subclass = nil, credentials: nil) require 'google/apis/storage_v1' if subclass.nil? - @@storage_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "StorageV1::StorageService", scopes: ['https://www.googleapis.com/auth/cloud-platform'], credentials: credentials) + @@storage_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "StorageV1::StorageService", scopes: ['cloud-platform'], credentials: credentials) return @@storage_api[credentials] elsif subclass.is_a?(Symbol) return Object.const_get("::Google").const_get("Apis").const_get("StorageV1").const_get(subclass) @@ -601,7 +804,7 @@ def self.iam(subclass = nil, credentials: nil) require 'google/apis/iam_v1' if subclass.nil? - @@iam_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "IamV1::IamService", scopes: ['https://www.googleapis.com/auth/cloud-platform'], credentials: credentials) + @@iam_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "IamV1::IamService", scopes: ['cloud-platform', 'cloudplatformprojects', 'cloudplatformorganizations', 'cloudplatformfolders'], credentials: credentials) return @@iam_api[credentials] elsif subclass.is_a?(Symbol) return Object.const_get("::Google").const_get("Apis").const_get("IamV1").const_get(subclass) @@ -612,18 +815,29 @@ def self.iam(subclass = nil, credentials: nil) # @param subclass []: If specified, will return the class ::Google::Apis::AdminDirectoryV1::subclass instead of an API client instance def self.admin_directory(subclass = nil, credentials: nil) require 'google/apis/admin_directory_v1' - - if subclass.nil? - begin - @@admin_directory_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "AdminDirectoryV1::DirectoryService", scopes: ['https://www.googleapis.com/auth/admin.directory.group.member.readonly', 'https://www.googleapis.com/auth/admin.directory.group.readonly', 'https://www.googleapis.com/auth/admin.directory.user.readonly', 'https://www.googleapis.com/auth/admin.directory.domain.readonly', 'https://www.googleapis.com/auth/admin.directory.orgunit.readonly', 'https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly', 'https://www.googleapis.com/auth/admin.directory.customer.readonly'], masquerade: MU::Cloud::Google.credConfig(credentials)['masquerade_as'], credentials: credentials) - rescue Signet::AuthorizationError => e - MU.log "Cannot masquerade as #{MU::Cloud::Google.credConfig(credentials)['masquerade_as']}", MU::ERROR, details: "You can only use masquerade_as with GSuite. For more information on delegating GSuite authority to a service account, see:\nhttps://developers.google.com/identity/protocols/OAuth2ServiceAccount#delegatingauthority" - raise e + + writescopes = ['admin.directory.group.member', 'admin.directory.group', 'admin.directory.user', 'admin.directory.domain', 'admin.directory.orgunit', 'admin.directory.rolemanagement', 'admin.directory.customer', 'admin.directory.user.alias', 'admin.directory.userschema'] + readscopes = ['admin.directory.group.member.readonly', 'admin.directory.group.readonly', 'admin.directory.user.readonly', 'admin.directory.domain.readonly', 'admin.directory.orgunit.readonly', 'admin.directory.rolemanagement.readonly', 'admin.directory.customer.readonly', 'admin.directory.user.alias.readonly', 'admin.directory.userschema.readonly'] + @@readonly_semaphore.synchronize { + use_scopes = readscopes+writescopes + if @@readonly[credentials] and @@readonly[credentials]["AdminDirectoryV1"] + use_scopes = readscopes.dup end - return @@admin_directory_api[credentials] - elsif subclass.is_a?(Symbol) - return Object.const_get("::Google").const_get("Apis").const_get("AdminDirectoryV1").const_get(subclass) - end + + if subclass.nil? + begin + @@admin_directory_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "AdminDirectoryV1::DirectoryService", scopes: use_scopes, masquerade: MU::Cloud::Google.credConfig(credentials)['masquerade_as'], credentials: credentials) + rescue Signet::AuthorizationError => e + MU.log "Falling back to read-only access to DirectoryService API for credential set '#{credentials}'", MU::WARN + @@admin_directory_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "AdminDirectoryV1::DirectoryService", scopes: readscopes, masquerade: MU::Cloud::Google.credConfig(credentials)['masquerade_as'], credentials: credentials) + @@readonly[credentials] ||= {} + @@readonly[credentials]["AdminDirectoryV1"] = true + end + return @@admin_directory_api[credentials] + elsif subclass.is_a?(Symbol) + return Object.const_get("::Google").const_get("Apis").const_get("AdminDirectoryV1").const_get(subclass) + end + } end # Google's Cloud Resource Manager API @@ -632,8 +846,10 @@ def self.resource_manager(subclass = nil, credentials: nil) require 'google/apis/cloudresourcemanager_v1' if subclass.nil? -# @@resource_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "CloudresourcemanagerV1::CloudResourceManagerService", scopes: ['https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/cloudplatformprojects'], masquerade: MU::Cloud::Google.credConfig(credentials)['masquerade_as'], credentials: credentials) - @@resource_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "CloudresourcemanagerV1::CloudResourceManagerService", scopes: ['https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/cloudplatformprojects'], credentials: credentials) + if !MU::Cloud::Google.credConfig(credentials) + raise MuError, "No such credential set #{credentials} defined in mu.yaml!" + end + @@resource_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "CloudresourcemanagerV1::CloudResourceManagerService", scopes: ['cloud-platform', 'cloudplatformprojects', 'cloudplatformorganizations', 'cloudplatformfolders'], credentials: credentials, masquerade: MU::Cloud::Google.credConfig(credentials)['masquerade_as']) return @@resource_api[credentials] elsif subclass.is_a?(Symbol) return Object.const_get("::Google").const_get("Apis").const_get("CloudresourcemanagerV1").const_get(subclass) @@ -646,7 +862,7 @@ def self.folder(subclass = nil, credentials: nil) require 'google/apis/cloudresourcemanager_v2' if subclass.nil? - @@resource2_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "CloudresourcemanagerV2::CloudResourceManagerService", scopes: ['https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/cloudplatformfolders'], credentials: credentials) + @@resource2_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "CloudresourcemanagerV2::CloudResourceManagerService", scopes: ['cloud-platform', 'cloudplatformfolders'], credentials: credentials, masquerade: MU::Cloud::Google.credConfig(credentials)['masquerade_as']) return @@resource2_api[credentials] elsif subclass.is_a?(Symbol) return Object.const_get("::Google").const_get("Apis").const_get("CloudresourcemanagerV2").const_get(subclass) @@ -659,7 +875,7 @@ def self.container(subclass = nil, credentials: nil) require 'google/apis/container_v1' if subclass.nil? - @@container_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "ContainerV1::ContainerService", scopes: ['https://www.googleapis.com/auth/cloud-platform'], credentials: credentials) + @@container_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "ContainerV1::ContainerService", scopes: ['cloud-platform'], credentials: credentials) return @@container_api[credentials] elsif subclass.is_a?(Symbol) return Object.const_get("::Google").const_get("Apis").const_get("ContainerV1").const_get(subclass) @@ -672,7 +888,7 @@ def self.service_manager(subclass = nil, credentials: nil) require 'google/apis/servicemanagement_v1' if subclass.nil? - @@service_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "ServicemanagementV1::ServiceManagementService", scopes: ['https://www.googleapis.com/auth/cloud-platform'], credentials: credentials) + @@service_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "ServicemanagementV1::ServiceManagementService", scopes: ['cloud-platform'], credentials: credentials) return @@service_api[credentials] elsif subclass.is_a?(Symbol) return Object.const_get("::Google").const_get("Apis").const_get("ServicemanagementV1").const_get(subclass) @@ -685,7 +901,7 @@ def self.sql(subclass = nil, credentials: nil) require 'google/apis/sqladmin_v1beta4' if subclass.nil? - @@sql_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "SqladminV1beta4::SQLAdminService", scopes: ['https://www.googleapis.com/auth/cloud-platform'], credentials: credentials) + @@sql_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "SqladminV1beta4::SQLAdminService", scopes: ['cloud-platform'], credentials: credentials) return @@sql_api[credentials] elsif subclass.is_a?(Symbol) return Object.const_get("::Google").const_get("Apis").const_get("SqladminV1beta4").const_get(subclass) @@ -698,7 +914,7 @@ def self.firestore(subclass = nil, credentials: nil) require 'google/apis/firestore_v1' if subclass.nil? - @@firestore_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "FirestoreV1::FirestoreService", scopes: ['https://www.googleapis.com/auth/cloud-platform'], credentials: credentials) + @@firestore_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "FirestoreV1::FirestoreService", scopes: ['cloud-platform'], credentials: credentials) return @@firestore_api[credentials] elsif subclass.is_a?(Symbol) return Object.const_get("::Google").const_get("Apis").const_get("FirestoreV1").const_get(subclass) @@ -711,7 +927,7 @@ def self.logging(subclass = nil, credentials: nil) require 'google/apis/logging_v2' if subclass.nil? - @@logging_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "LoggingV2::LoggingService", scopes: ['https://www.googleapis.com/auth/cloud-platform'], credentials: credentials) + @@logging_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "LoggingV2::LoggingService", scopes: ['cloud-platform'], credentials: credentials) return @@logging_api[credentials] elsif subclass.is_a?(Symbol) return Object.const_get("::Google").const_get("Apis").const_get("LoggingV2").const_get(subclass) @@ -724,26 +940,86 @@ def self.billing(subclass = nil, credentials: nil) require 'google/apis/cloudbilling_v1' if subclass.nil? - @@billing_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "CloudbillingV1::CloudbillingService", scopes: ['https://www.googleapis.com/auth/cloud-platform'], credentials: credentials) + @@billing_api[credentials] ||= MU::Cloud::Google::GoogleEndpoint.new(api: "CloudbillingV1::CloudbillingService", scopes: ['cloud-platform', 'cloud-billing'], credentials: credentials, masquerade: MU::Cloud::Google.credConfig(credentials)['masquerade_as']) return @@billing_api[credentials] elsif subclass.is_a?(Symbol) return Object.const_get("::Google").const_get("Apis").const_get("CloudbillingV1").const_get(subclass) end end + # Retrieve the domains, if any, which these credentials can manage via + # GSuite or Cloud Identity. + # @param credentials [String] + # @return [Array],nil] + def self.getDomains(credentials = nil) + my_org = getOrg(credentials) + return nil if !my_org + + resp = MU::Cloud::Google.admin_directory(credentials: credentials).list_domains(MU::Cloud::Google.customerID(credentials)) + resp.domains.map { |d| d.domain_name.downcase } + end + @@orgmap = {} # Retrieve the organization, if any, to which these credentials belong. # @param credentials [String] # @return [Array],nil] - def self.getOrg(credentials = nil) - resp = MU::Cloud::Google.resource_manager(credentials: credentials).search_organizations + def self.getOrg(credentials = nil, with_id: nil) + creds = MU::Cloud::Google.credConfig(credentials) + credname = if creds and creds['name'] + creds['name'] + else + "default" + end + + return @@orgmap[credname] if @@orgmap.has_key?(credname) + resp = MU::Cloud::Google.resource_manager(credentials: credname).search_organizations if resp and resp.organizations # XXX no idea if it's possible to be a member of multiple orgs - return resp.organizations.first + if !with_id + @@orgmap[credname] = resp.organizations.first + return resp.organizations.first + else + resp.organizations.each { |org| + if org.name == with_id + @@orgmap[credname] = org + return org + end + } + return nil + end end + + @@orgmap[credname] = nil + + + MU.log "Unable to list_organizations with credentials #{credname}. If this account is part of a GSuite or Cloud Identity domain, verify that Oauth delegation is properly configured and that 'masquerade_as' is properly set for the #{credname} Google credential set in mu.yaml.", MU::ERR, details: ["https://cloud.google.com/resource-manager/docs/creating-managing-organization", "https://admin.google.com/AdminHome?chromeless=1#OGX:ManageOauthClients"] + nil end + @@customer_ids_cache = {} + + # Fetch the GSuite/Cloud Identity customer id for the domain associated + # with the given credentials, if a domain is set via the +masquerade_as+ + # configuration option. + def self.customerID(credentials = nil) + cfg = credConfig(credentials) + if !cfg or !cfg['masquerade_as'] + return nil + end + + if @@customer_ids_cache[credentials] + return @@customer_ids_cache[credentials] + end + + user = MU::Cloud::Google.admin_directory(credentials: credentials).get_user(cfg['masquerade_as']) + if user and user.customer_id + @@customer_ids_cache[credentials] = user.customer_id + end + + @@customer_ids_cache[credentials] + end + private # Wrapper class for Google APIs, so that we can catch some common @@ -752,18 +1028,37 @@ def self.getOrg(credentials = nil) class GoogleEndpoint @api = nil @credentials = nil + @scopes = nil + @masquerade = nil attr_reader :issuer # Create a Google Cloud Platform API client # @param api [String]: Which API are we wrapping? # @param scopes [Array]: Google auth scopes applicable to this API - def initialize(api: "ComputeBeta::ComputeService", scopes: ['https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/compute.readonly'], masquerade: nil, credentials: nil) + def initialize(api: "ComputeV1::ComputeService", scopes: ['https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/compute.readonly'], masquerade: nil, credentials: nil) @credentials = credentials + @scopes = scopes.map { |s| + if !s.match(/\//) # allow callers to use shorthand + s = "https://www.googleapis.com/auth/"+s + end + s + } + @masquerade = masquerade @api = Object.const_get("Google::Apis::#{api}").new - @api.authorization = MU::Cloud::Google.loadCredentials(scopes, credentials: credentials) - if masquerade - @api.authorization.sub = masquerade - @api.authorization.fetch_access_token! + @api.authorization = MU::Cloud::Google.loadCredentials(@scopes, credentials: credentials) + raise MuError, "No useable Google credentials found#{credentials ? " with set '#{credentials}'" : ""}" if @api.authorization.nil? + if @masquerade + begin + @api.authorization.sub = @masquerade + @api.authorization.fetch_access_token! + rescue Signet::AuthorizationError => e + MU.log "Cannot masquerade as #{@masquerade} to API #{api}: #{e.message}", MU::ERROR, details: @scopes + if e.message.match(/client not authorized for any of the scopes requested/) +# XXX it'd be helpful to list *all* scopes we like, as well as the API client's numeric id + MU.log "To grant access to API scopes for this service account, see:", MU::ERR, details: "https://admin.google.com/AdminHome?chromeless=1#OGX:ManageOauthClients" + end + raise e + end end @issuer = @api.authorization.issuer end @@ -780,9 +1075,9 @@ def delete(type, project, region = nil, noop = false, filter = "description eq # resp = nil begin if region - resp = MU::Cloud::Google.compute(credentials: @credentials).send(list_sym, project, region, filter: filter) + resp = MU::Cloud::Google.compute(credentials: @credentials).send(list_sym, project, region, filter: filter, mu_gcp_enable_apis: false) else - resp = MU::Cloud::Google.compute(credentials: @credentials).send(list_sym, project, filter: filter) + resp = MU::Cloud::Google.compute(credentials: @credentials).send(list_sym, project, filter: filter, mu_gcp_enable_apis: false) end rescue ::Google::Apis::ClientError => e @@ -795,6 +1090,7 @@ def delete(type, project, region = nil, noop = false, filter = "description eq # resp.items.each { |obj| threads << Thread.new { MU.dupGlobals(parent_thread_id) + Thread.abort_on_exception = false MU.log "Removing #{type.gsub(/_/, " ")} #{obj.name}" delete_sym = "delete_#{type}".to_sym if !noop @@ -813,9 +1109,10 @@ def delete(type, project, region = nil, noop = false, filter = "description eq # failed = true retries += 1 if resp.error.errors.first.code == "RESOURCE_IN_USE_BY_ANOTHER_RESOURCE" and retries < 6 - sleep 15 + sleep 10 else MU.log "Error deleting #{type.gsub(/_/, " ")} #{obj.name}", MU::ERR, details: resp.error.errors + Thread.abort_on_exception = false raise MuError, "Failed to delete #{type.gsub(/_/, " ")} #{obj.name}" end else @@ -824,6 +1121,8 @@ def delete(type, project, region = nil, noop = false, filter = "description eq # # TODO validate that the resource actually went away, because it seems not to do so very reliably rescue ::Google::Apis::ClientError => e raise e if !e.message.match(/(^notFound: |operation in progress)/) + rescue MU::Cloud::MuDefunctHabitat => e + # this is ok- it's already deleted end while failed and retries < 6 end } @@ -841,10 +1140,31 @@ def delete(type, project, region = nil, noop = false, filter = "description eq # def method_missing(method_sym, *arguments) retries = 0 actual_resource = nil + + enable_on_fail = true + arguments.each { |arg| + if arg.is_a?(Hash) and arg.has_key?(:mu_gcp_enable_apis) + enable_on_fail = arg[:mu_gcp_enable_apis] + arg.delete(:mu_gcp_enable_apis) + + end + } + arguments.delete({}) + next_page_token = nil + overall_retval = nil + begin MU.log "Calling #{method_sym}", MU::DEBUG, details: arguments retval = nil retries = 0 + wait_backoff = 5 + if next_page_token + if arguments.size == 1 and arguments.first.is_a?(Hash) + arguments[0][:page_token] = next_page_token + else + arguments << { :page_token => next_page_token } + end + end begin if !arguments.nil? and arguments.size == 1 retval = @api.method(method_sym).call(arguments[0]) @@ -853,49 +1173,93 @@ def method_missing(method_sym, *arguments) else retval = @api.method(method_sym).call end + rescue ArgumentError => e + MU.log "#{e.class.name} calling #{@api.class.name}.#{method_sym.to_s}: #{e.message}", MU::ERR, details: arguments + raise e rescue ::Google::Apis::AuthorizationError => e if arguments.size > 0 raise MU::MuError, "Service account #{MU::Cloud::Google.svc_account_name} has insufficient privileges to call #{method_sym} in project #{arguments.first}" else raise MU::MuError, "Service account #{MU::Cloud::Google.svc_account_name} has insufficient privileges to call #{method_sym}" end + rescue ::Google::Apis::RateLimitError, ::Google::Apis::TransmissionError, ::ThreadError, ::Google::Apis::ServerError => e + if retries <= 10 + sleep wait_backoff + retries += 1 + wait_backoff = wait_backoff * 2 + retry + else + raise e + end rescue ::Google::Apis::ClientError, OpenSSL::SSL::SSLError => e - if e.message.match(/^invalidParameter:/) - MU.log "#{method_sym.to_s}: "+e.message, MU::ERR, details: arguments + if e.message.match(/^quotaExceeded: Request rate/) + if retries <= 10 + sleep wait_backoff + retries += 1 + wait_backoff = wait_backoff * 2 + retry + else + raise e + end + elsif e.message.match(/^invalidParameter:|^badRequest:/) + MU.log "#{e.class.name} calling #{@api.class.name}.#{method_sym.to_s}: "+e.message, MU::ERR, details: arguments # uncomment for debugging stuff; this can occur in benign situations so we don't normally want it logging elsif e.message.match(/^forbidden:/) - MU.log "Using credentials #{@credentials}: #{method_sym.to_s}: "+e.message, MU::ERR, details: caller + MU.log "#{e.class.name} calling #{@api.class.name}.#{method_sym.to_s} got \"#{e.message}\" using credentials #{@credentials}#{@masquerade ? " (OAuth'd as #{@masquerade})": ""}.#{@scopes ? "\nScopes:\n#{@scopes.join("\n")}" : "" }", MU::DEBUG, details: arguments + raise e end @@enable_semaphores ||= {} max_retries = 3 wait_time = 90 - if retries <= max_retries and e.message.match(/^accessNotConfigured/) + if enable_on_fail and retries <= max_retries and e.message.match(/^accessNotConfigured/) enable_obj = nil - project = arguments.size > 0 ? arguments.first.to_s : MU::Cloud::Google.defaultProject(@credentials) + + project = if arguments.size > 0 and arguments.first.is_a?(String) + arguments.first + else + MU::Cloud::Google.defaultProject(@credentials) + end +# XXX validate that this actually looks like a project id, maybe + if method_sym == :delete and !MU::Cloud::Google::Habitat.isLive?(project, @credentials) + MU.log "Got accessNotConfigured while attempting to delete a resource in #{project}", MU::WARN + + return + end + @@enable_semaphores[project] ||= Mutex.new enable_obj = MU::Cloud::Google.service_manager(:EnableServiceRequest).new( consumer_id: "project:"+project ) # XXX dumbass way to get this string - e.message.match(/by visiting https:\/\/console\.developers\.google\.com\/apis\/api\/(.+?)\//) - - svc_name = Regexp.last_match[1] - save_verbosity = MU.verbosity - if svc_name != "servicemanagement.googleapis.com" - retries += 1 - @@enable_semaphores[project].synchronize { + if e.message.match(/by visiting https:\/\/console\.developers\.google\.com\/apis\/api\/(.+?)\//) + + svc_name = Regexp.last_match[1] + save_verbosity = MU.verbosity + if svc_name != "servicemanagement.googleapis.com" and method_sym != :delete + retries += 1 + @@enable_semaphores[project].synchronize { + MU.setLogging(MU::Logger::NORMAL) + MU.log "Attempting to enable #{svc_name} in project #{project}; will retry #{method_sym.to_s} in #{(wait_time/retries).to_s}s (#{retries.to_s}/#{max_retries.to_s})", MU::NOTICE + MU.setLogging(save_verbosity) + begin + MU::Cloud::Google.service_manager(credentials: @credentials).enable_service(svc_name, enable_obj) + rescue ::Google::Apis::ClientError => e + MU.log "Error enabling #{svc_name} in #{project} for #{method_sym.to_s}: "+ e.message, MU::ERR, details: enable_obj + raise e + end + } + sleep wait_time/retries + retry + else MU.setLogging(MU::Logger::NORMAL) - MU.log "Attempting to enable #{svc_name} in project #{project}; will retry #{method_sym.to_s} in #{(wait_time/retries).to_s}s (#{retries.to_s}/#{max_retries.to_s})", MU::NOTICE + MU.log "Google Cloud's Service Management API must be enabled manually by visiting #{e.message.gsub(/.*?(https?:\/\/[^\s]+)(?:$|\s).*/, '\1')}", MU::ERR MU.setLogging(save_verbosity) - MU::Cloud::Google.service_manager(credentials: @credentials).enable_service(svc_name, enable_obj) - } - sleep wait_time/retries - retry + raise MU::MuError, "Service Management API not yet enabled for this account/project" + end + elsif e.message.match(/scheduled for deletion and cannot be used for API calls/) + raise MuDefunctHabitat, e.message else - MU.setLogging(MU::Logger::NORMAL) - MU.log "Google Cloud's Service Management API must be enabled manually by visiting #{e.message.gsub(/.*?(https?:\/\/[^\s]+)(?:$|\s).*/, '\1')}", MU::ERR - MU.setLogging(save_verbosity) - raise MU::MuError, "Service Management API not yet enabled for this account/project" + MU.log "Unfamiliar error calling #{method_sym.to_s} "+e.message, MU::ERR, details: arguments end elsif retries <= 10 and e.message.match(/^resourceNotReady:/) or @@ -914,9 +1278,17 @@ def method_missing(method_sym, *arguments) end end - if retval.class == ::Google::Apis::ComputeBeta::Operation + if retval.class.name.match(/.*?::Operation$/) + retries = 0 orig_target = retval.name + + # Check whether the various types of +Operation+ responses say + # they're done, without knowing which specific API they're from + def is_done?(retval) + (retval.respond_to?(:status) and retval.status == "DONE") or (retval.respond_to?(:done) and retval.done) + end + begin if retries > 0 and retries % 3 == 0 MU.log "Waiting for #{method_sym} to be done (retry #{retries})", MU::NOTICE @@ -924,14 +1296,37 @@ def method_missing(method_sym, *arguments) MU.log "Waiting for #{method_sym} to be done (retry #{retries})", MU::DEBUG, details: retval end - if retval.status != "DONE" + if !is_done?(retval) sleep 7 begin - resp = MU::Cloud::Google.compute(credentials: @credentials).get_global_operation( - arguments.first, # there's always a project id - retval.name - ) - retval = resp + if retval.class.name.match(/::Compute[^:]*::/) + resp = MU::Cloud::Google.compute(credentials: @credentials).get_global_operation( + arguments.first, # there's always a project id + retval.name + ) + retval = resp + elsif retval.class.name.match(/::Servicemanagement[^:]*::/) + resp = MU::Cloud::Google.service_manager(credentials: @credentials).get_operation( + retval.name + ) + retval = resp + elsif retval.class.name.match(/::Cloudresourcemanager[^:]*::/) + resp = MU::Cloud::Google.resource_manager(credentials: @credentials).get_operation( + retval.name + ) + retval = resp + if retval.error + raise MuError, retval.error.message + end + elsif retval.class.name.match(/::Container[^:]*::/) + resp = MU::Cloud::Google.container(credentials: @credentials).get_project_location_operation( + retval.self_link.sub(/.*?\/projects\//, 'projects/') + ) + retval = resp + else + pp retval + raise MuError, "I NEED TO IMPLEMENT AN OPERATION HANDLER FOR #{retval.class.name}" + end rescue ::Google::Apis::ClientError => e # this is ok; just means the operation is done and went away if e.message.match(/^notFound:/) @@ -942,7 +1337,8 @@ def method_missing(method_sym, *arguments) end retries = retries + 1 end - end while retval.status != "DONE" + + end while !is_done?(retval) # Most insert methods have a predictable get_* counterpart. Let's # take advantage. @@ -960,6 +1356,10 @@ def method_missing(method_sym, *arguments) faked_args.pop end faked_args.push(cloud_id) + if get_method == :get_project_location_cluster + faked_args[0] = faked_args[0]+"/clusters/"+faked_args[1] + faked_args.pop + end actual_resource = @api.method(get_method).call(*faked_args) #if method_sym == :insert_instance #MU.log "actual_resource", MU::WARN, details: actual_resource @@ -982,7 +1382,43 @@ def method_missing(method_sym, *arguments) return actual_resource end end - return retval + + # This atrocity appends the pages of list_* results + if overall_retval + if method_sym.to_s.match(/^list_(.*)/) + require 'google/apis/iam_v1' + what = Regexp.last_match[1].to_sym + whatassign = (Regexp.last_match[1]+"=").to_sym + if overall_retval.class == ::Google::Apis::IamV1::ListServiceAccountsResponse + what = :accounts + whatassign = :accounts= + end + if retval.respond_to?(what) and retval.respond_to?(whatassign) + if !retval.public_send(what).nil? + newarray = retval.public_send(what) + overall_retval.public_send(what) + overall_retval.public_send(whatassign, newarray) + end + else + MU.log "Not sure how to append #{method_sym.to_s} results to #{overall_retval.class.name} (apparently #{what.to_s} and #{whatassign.to_s} aren't it), returning first page only", MU::WARN, details: retval + return retval + end + else + MU.log "Not sure how to append #{method_sym.to_s} results, returning first page only", MU::WARN, details: retval + return retval + end + else + overall_retval = retval + end + + arguments.delete({ :page_token => next_page_token }) + next_page_token = nil + + if retval.respond_to?(:next_page_token) and !retval.next_page_token.nil? + next_page_token = retval.next_page_token + MU.log "Getting another page of #{method_sym.to_s}", MU::DEBUG, details: next_page_token + else + return overall_retval + end rescue ::Google::Apis::ServerError, ::Google::Apis::ClientError, ::Google::Apis::TransmissionError => e if e.class.name == "Google::Apis::ClientError" and (!method_sym.to_s.match(/^insert_/) or !e.message.match(/^notFound: /) or @@ -996,8 +1432,8 @@ def method_missing(method_sym, *arguments) logs = MU::Cloud::Google.logging(credentials: @credentials).list_entry_log_entries(logreq) details = nil if logs.entries - details = logs.entries.map { |e| e.json_payload } - details.reject! { |e| e["error"].nil? or e["error"].size == 0 } + details = logs.entries.map { |err| err.json_payload } + details.reject! { |err| err["error"].nil? or err["error"].size == 0 } end raise MuError, "#{method_sym.to_s} of #{retval.target_id} appeared to succeed, but then the resource disappeared! #{details.to_s}" @@ -1022,7 +1458,7 @@ def method_missing(method_sym, *arguments) sleep interval MU.log method_sym.to_s.bold+" "+e.inspect, MU::WARN, details: arguments retry - end + end while !next_page_token.nil? end end diff --git a/modules/mu/clouds/google/bucket.rb b/modules/mu/clouds/google/bucket.rb index a850288c0..94bb6adf4 100644 --- a/modules/mu/clouds/google/bucket.rb +++ b/modules/mu/clouds/google/bucket.rb @@ -17,34 +17,17 @@ class Cloud class Google # Support for Google Cloud Storage class Bucket < MU::Cloud::Bucket - @deploy = nil - @config = nil - @project_id = nil - - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::logs} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - if mu_name - @mu_name = mu_name - @config['project'] ||= MU::Cloud::Google.defaultProject(@config['credentials']) - if !@project_id - project = MU::Cloud::Google.projectLookup(@config['project'], @deploy, sibling_only: true, raise_on_fail: false) - @project_id = project.nil? ? @config['project'] : project.cloudobj.cloud_id - end - end + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @mu_name ||= @deploy.getResourceName(@config["name"]) end # Called automatically by {MU::Deploy#createResources} def create - @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloudobj.cloud_id + @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloud_id MU::Cloud::Google.storage(credentials: credentials).insert_bucket(@project_id, bucket_descriptor) @cloud_id = @mu_name.downcase end @@ -72,6 +55,18 @@ def groom changed = true end + if @config['bucket_wide_acls'] and (!current.iam_configuration or + !current.iam_configuration.bucket_policy_only or + !current.iam_configuration.bucket_policy_only.enabled) + MU.log "Converting Cloud Storage bucket #{@cloud_id} to use bucket-wide ACLs only", MU::NOTICE + changed = true + elsif !@config['bucket_wide_acls'] and current.iam_configuration and + current.iam_configuration.bucket_policy_only and + current.iam_configuration.bucket_policy_only.enabled + MU.log "Converting Cloud Storage bucket #{@cloud_id} to use bucket and object ACLs", MU::NOTICE + changed = true + end + if changed MU::Cloud::Google.storage(credentials: credentials).patch_bucket(@cloud_id, bucket_descriptor) end @@ -79,18 +74,19 @@ def groom if @config['policies'] @config['policies'].each { |pol| pol['grant_to'].each { |grantee| + grantee['id'] ||= grantee["identifier"] entity = if grantee["type"] sibling = deploy_obj.findLitterMate( - name: grantee["identifier"], + name: grantee["id"], type: grantee["type"] ) if sibling sibling.cloudobj.cloud_id else - raise MuError, "Couldn't find a #{grantee["type"]} named #{grantee["identifier"]} when generating Cloud Storage access policy" + raise MuError, "Couldn't find a #{grantee["type"]} named #{grantee["id"]} when generating Cloud Storage access policy" end else - pol['grant_to'].first['identifier'] + pol['grant_to'].first['id'] end if entity.match(/@/) and !entity.match(/^(group|user)\-/) @@ -123,6 +119,14 @@ def groom end end + # Upload a file to a bucket. + # @param url [String]: Target URL, of the form gs://bucket/folder/file + # @param acl [String]: Canned ACL permission to assign to the object we upload + # @param file [String]: Path to a local file to write to our target location. One of +file+ or +data+ must be specified. + # @param data [String]: Data to write to our target location. One of +file+ or +data+ must be specified. + def self.upload(url, acl: "private", file: nil, data: nil, credentials: nil) + end + # Does this resource type exist as a global (cloud-wide) artifact, or # is it localized to a region/zone? # @return [Boolean] @@ -166,18 +170,135 @@ def notify end # Locate an existing bucket. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags # @return [OpenStruct]: The cloud provider's complete descriptions of matching bucket. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}, tag_key: nil, tag_value: nil) + def self.find(**args) + args[:project] ||= args[:habitat] + args[:project] ||= MU::Cloud::Google.defaultProject(args[:credentials]) + found = {} - if cloud_id - found[cloud_id] = MU::Cloud::Google.storage(credentials: credentials).get_bucket(cloud_id) + if args[:cloud_id] + found[args[:cloud_id]] = MU::Cloud::Google.storage(credentials: args[:credentials]).get_bucket(args[:cloud_id]) + else + resp = begin + MU::Cloud::Google.storage(credentials: args[:credentials]).list_buckets(args[:project]) + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/forbidden:/) + end + + if resp and resp.items + resp.items.each { |bucket| + found[bucket.id] = bucket + } + end end + found end + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + def toKitten(rootparent: nil, billing: nil, habitats: nil) + bok = { + "cloud" => "Google", + "credentials" => @config['credentials'], + "cloud_id" => @cloud_id + } + + bok['name'] = cloud_desc.name + bok['project'] = @project_id + bok['storage_class'] = cloud_desc.storage_class + if cloud_desc.versioning and cloud_desc.versioning.enabled + bok['versioning'] = true + end + if cloud_desc.website + bok['web'] = true + if cloud_desc.website.not_found_page + bok['web_error_object'] = cloud_desc.website.not_found_page + end + if cloud_desc.website.main_page_suffix + bok['web_index_object'] = cloud_desc.website.main_page_suffix + end + pp cloud_desc + end + +# MU.log "get_bucket_iam_policy", MU::NOTICE, details: MU::Cloud::Google.storage(credentials: @credentials).get_bucket_iam_policy(@cloud_id) + pols = MU::Cloud::Google.storage(credentials: @credentials).get_bucket_iam_policy(@cloud_id) + + if pols and pols.bindings and pols.bindings.size > 0 + bok['policies'] = [] + count = 0 + grantees = {} + pols.bindings.each { |binding| + grantees[binding.role] ||= [] + binding.members.each { |grantee| + if grantee.match(/^(user|group):(.*)/) + grantees[binding.role] << MU::Config::Ref.get( + id: Regexp.last_match[2], + type: Regexp.last_match[1]+"s", + cloud: "Google", + credentials: @credentials + ) + elsif grantee == "allUsers" or + grantee == "allAuthenticatedUsers" or + grantee.match(/^project(?:Owner|Editor|Viewer):/) + grantees[binding.role] << { "id" => grantee } + elsif grantee.match(/^serviceAccount:(.*)/) + sa_name = Regexp.last_match[1] + if MU::Cloud::Google::User.cannedServiceAcctName?(sa_name) + grantees[binding.role] << { "id" => grantee } + else + grantees[binding.role] << MU::Config::Ref.get( + id: sa_name, + type: "users", + cloud: "Google", + credentials: @credentials + ) + end + else + # *shrug* + grantees[binding.role] << { "id" => grantee } + end + } + } + + # munge together roles that apply to the exact same set of + # principals + reverse_map = {} + grantees.each_pair { |perm, grant_to| + reverse_map[grant_to] ||= [] + reverse_map[grant_to] << perm + } + already_done = [] + + grantees.each_pair { |perm, grant_to| + if already_done.include?(perm+grant_to.to_s) + next + end + bok['policies'] << { + "name" => "policy"+count.to_s, + "grant_to" => grant_to, + "permissions" => reverse_map[grant_to] + } + reverse_map[grant_to].each { |doneperm| + already_done << doneperm+grant_to.to_s + } + count = count+1 + } + end + + if cloud_desc.iam_configuration and + cloud_desc.iam_configuration.bucket_policy_only and + cloud_desc.iam_configuration.bucket_policy_only.enabled + bok['bucket_wide_acls'] = true + else +# MU.log "list_bucket_access_controls", MU::NOTICE, details: MU::Cloud::Google.storage(credentials: @credentials).list_bucket_access_controls(@cloud_id) +# MU.log "list_default_object_access_controls", MU::NOTICE, details: MU::Cloud::Google.storage(credentials: @credentials).list_default_object_access_controls(@cloud_id) + end + + bok + end + # Cloud-specific configuration properties. # @param config [MU::Config]: The calling MU::Config object # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource @@ -188,6 +309,11 @@ def self.schema(config) "type" => "string", "enum" => ["MULTI_REGIONAL", "REGIONAL", "STANDARD", "NEARLINE", "COLDLINE", "DURABLE_REDUCED_AVAILABILITY"], "default" => "STANDARD" + }, + "bucket_wide_acls" => { + "type" => "boolean", + "default" => false, + "description" => "Disables object-level access controls in favor of bucket-wide policies" } } [toplevel_required, schema] @@ -200,6 +326,7 @@ def self.schema(config) # @return [Boolean]: True if validation succeeded, False otherwise def self.validateConfig(bucket, configurator) ok = true + bucket['project'] ||= MU::Cloud::Google.defaultProject(bucket['credentials']) if bucket['policies'] bucket['policies'].each { |pol| @@ -245,6 +372,20 @@ def bucket_descriptor params[:versioning] = MU::Cloud::Google.storage(:Bucket)::Versioning.new(enabled: false) end + if @config['bucket_wide_acls'] + params[:iam_configuration] = MU::Cloud::Google.storage(:Bucket)::IamConfiguration.new( + bucket_policy_only: MU::Cloud::Google.storage(:Bucket)::IamConfiguration::BucketPolicyOnly.new( + enabled: @config['bucket_wide_acls'] + ) + ) + else + params[:iam_configuration] = MU::Cloud::Google.storage(:Bucket)::IamConfiguration.new( + bucket_policy_only: MU::Cloud::Google.storage(:Bucket)::IamConfiguration::BucketPolicyOnly.new( + enabled: false + ) + ) + end + MU::Cloud::Google.storage(:Bucket).new(params) end diff --git a/modules/mu/clouds/google/container_cluster.rb b/modules/mu/clouds/google/container_cluster.rb index a85be0378..dc29589c8 100644 --- a/modules/mu/clouds/google/container_cluster.rb +++ b/modules/mu/clouds/google/container_cluster.rb @@ -1,4 +1,4 @@ -# Copyright:: Copyright (c) 2014 eGlobalTech, Inc., all rights reserved +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved # # Licensed under the BSD-3 license (the "License"); # you may not use this file except in compliance with the License. @@ -17,33 +17,13 @@ class Cloud class Google # A Kubernetes cluster as configured in {MU::Config::BasketofKittens::container_clusters} class ContainerCluster < MU::Cloud::ContainerCluster - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :cloud_id - attr_reader :config - attr_reader :groomer - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::container_clusters} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - # @mu_name = mu_name ? mu_name : @deploy.getResourceName(@config["name"]) - @config["groomer"] = MU::Config.defaultGroomer unless @config["groomer"] - @groomclass = MU::Groomer.loadGroomer(@config["groomer"]) - - if !mu_name.nil? - @mu_name = mu_name - deploydata = describe[2] - @config['availability_zone'] = deploydata['zone'] - @config['project'] ||= MU::Cloud::Google.defaultProject(@config['credentials']) - if !@project_id - project = MU::Cloud::Google.projectLookup(@config['project'], @deploy, sibling_only: true, raise_on_fail: false) - @project_id = project.nil? ? @config['project'] : project.cloudobj.cloud_id - end - else + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + + if !@mu_name @mu_name ||= @deploy.getResourceName(@config["name"], max_length: 40) end end @@ -52,16 +32,11 @@ def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) # Called automatically by {MU::Deploy#createResources} # @return [String]: The cloud provider's identifier for this GKE instance. def create - labels = {} - MU::MommaCat.listStandardTags.each_pair { |name, value| - if !value.nil? - labels[name.downcase] = value.downcase.gsub(/[^a-z0-9\-\_]/i, "_") - end - } + labels = Hash[@tags.keys.map { |k| + [k.downcase, @tags[k].downcase.gsub(/[^-_a-z0-9]/, '-')] } + ] labels["name"] = MU::Cloud::Google.nameStr(@mu_name) - @config['availability_zone'] ||= MU::Cloud::Google.listAZs(@config['region']).sample - if @vpc.nil? and @config['vpc'] and @config['vpc']['vpc_name'] @vpc = @deploy.findLitterMate(name: @config['vpc']['vpc_name'], type: "vpcs") end @@ -70,124 +45,673 @@ def create raise MuError, "ContainerCluster #{@config['name']} unable to locate its resident VPC from #{@config['vpc']}" end - subnet = nil - @vpc.subnets.each { |s| - if s.az == @config['region'] - subnet = s - break + + sa = MU::Config::Ref.get(@config['service_account']) + if sa.name and @deploy.findLitterMate(name: sa.name, type: "users") + @service_acct = @deploy.findLitterMate(name: sa.name, type: "users").cloud_desc + else + if !sa or !sa.kitten or !sa.kitten.cloud_desc + raise MuError, "Failed to get service account cloud id from #{@config['service_account'].to_s}" end - } -puts @config['credentials'] - service_acct = MU::Cloud::Google::Server.createServiceAccount( - @mu_name.downcase, - @deploy, - project: @config['project'], - credentials: @config['credentials'] - ) - MU::Cloud::Google.grantDeploySecretAccess(service_acct.email, credentials: @config['credentials']) + @service_acct = sa.kitten.cloud_desc + end + if !@config['scrub_mu_isms'] + MU::Cloud::Google.grantDeploySecretAccess(@service_acct.email, credentials: @config['credentials']) + end - @config['ssh_user'] ||= "mu" + @config['ssh_user'] ||= "muadmin" - node_desc = { - :machine_type => @config['instance_type'], - :preemptible => @config['preemptible'], - :disk_size_gb => @config['disk_size_gb'], - :labels => labels, - :tags => [@mu_name.downcase], - :service_account => service_acct.email, - :oauth_scopes => ["https://www.googleapis.com/auth/compute", "https://www.googleapis.com/auth/devstorage.read_only"], - :metadata => { - "ssh-keys" => @config['ssh_user']+":"+@deploy.ssh_public_key - } - } - [:local_ssd_count, :min_cpu_platform, :image_type].each { |field| - if @config[field.to_s] - node_desc[field] = @config[field.to_s] - end - } - nodeobj = MU::Cloud::Google.container(:NodeConfig).new(node_desc) + nodeobj = if @config['min_size'] and @config['max_size'] + MU::Cloud::Google.container(:NodePool).new( + name: @mu_name.downcase, + initial_node_count: @config['instance_count'] || @config['min_size'], + autoscaling: MU::Cloud::Google.container(:NodePoolAutoscaling).new( + enabled: true, + min_node_count: @config['min_size'], + max_node_count: @config['max_size'], + ), + management: MU::Cloud::Google.container(:NodeManagement).new( + auto_upgrade: @config['auto_upgrade'], + auto_repair: @config['auto_repair'] + ), + config: MU::Cloud::Google.container(:NodeConfig).new(node_desc) + ) + else + MU::Cloud::Google.container(:NodeConfig).new(node_desc) + end + locations = if @config['availability_zone'] + [@config['availability_zone']] + else + MU::Cloud::Google.listAZs(@config['region']) + end + + master_user = @config['master_user'] + # We'll create a temporary basic auth config so that we can grant + # useful permissions to the Client Certificate user + master_user ||= "master_user" + master_pw = Password.pronounceable(18) desc = { :name => @mu_name.downcase, :description => @deploy.deploy_id, :network => @vpc.cloud_id, - :subnetwork => subnet.cloud_id, - :labels => labels, + :enable_tpu => @config['tpu'], :resource_labels => labels, - :initial_cluster_version => @config['kubernetes']['version'], - :initial_node_count => @config['instance_count'], - :locations => MU::Cloud::Google.listAZs(@config['region']), - :node_config => nodeobj + :locations => locations, + :master_auth => MU::Cloud::Google.container(:MasterAuth).new( + :client_certificate_config => MU::Cloud::Google.container(:ClientCertificateConfig).new( + :issue_client_certificate => true + ), + :username => master_user, + :password => master_pw + ), } + if @config['kubernetes'] + desc[:addons_config] = MU::Cloud::Google.container(:AddonsConfig).new( + horizontal_pod_autoscaling: MU::Cloud::Google.container(:HorizontalPodAutoscaling).new( + disabled: !@config['kubernetes']['horizontal_pod_autoscaling'] + ), + http_load_balancing: MU::Cloud::Google.container(:HttpLoadBalancing).new( + disabled: !@config['kubernetes']['http_load_balancing'] + ), + kubernetes_dashboard: MU::Cloud::Google.container(:KubernetesDashboard).new( + disabled: !@config['kubernetes']['dashboard'] + ), + network_policy_config: MU::Cloud::Google.container(:NetworkPolicyConfig).new( + disabled: !@config['kubernetes']['network_policy_addon'] + ) + ) + end + + # Pick an existing subnet from our VPC, if we're not going to create + # one. + if !@config['custom_subnet'] + @vpc.subnets.each { |s| + if s.az == @config['region'] + desc[:subnetwork] = s.cloud_id + break + end + } + end + if @config['log_facility'] == "kubernetes" + desc[:logging_service] = "logging.googleapis.com/kubernetes" + desc[:monitoring_service] = "monitoring.googleapis.com/kubernetes" + elsif @config['log_facility'] == "basic" + desc[:logging_service] = "logging.googleapis.com" + desc[:monitoring_service] = "monitoring.googleapis.com" + else + desc[:logging_service] = "none" + desc[:monitoring_service] = "none" + end + + if nodeobj.is_a?(::Google::Apis::ContainerV1::NodeConfig) + desc[:node_config] = nodeobj + desc[:initial_node_count] = @config['instance_count'] + else + desc[:node_pools] = [nodeobj] + end + + if @config['kubernetes'] + if @config['kubernetes']['version'] + desc[:initial_cluster_version] = @config['kubernetes']['version'] + end + if @config['kubernetes']['alpha'] + desc[:enable_kubernetes_alpha] = @config['kubernetes']['alpha'] + end + end + + if @config['preferred_maintenance_window'] + desc[:maintenance_policy] = MU::Cloud::Google.container(:MaintenancePolicy).new( + window: MU::Cloud::Google.container(:MaintenanceWindow).new( + daily_maintenance_window: MU::Cloud::Google.container(:DailyMaintenanceWindow).new( + start_time: @config['preferred_maintenance_window'] + ) + ) + ) + end + + if @config['private_cluster'] + desc[:private_cluster_config] = MU::Cloud::Google.container(:PrivateClusterConfig).new( + enable_private_endpoint: @config['private_cluster']['private_master'], + enable_private_nodes: @config['private_cluster']['private_nodes'], + master_ipv4_cidr_block: @config['private_cluster']['master_ip_block'] + ) + desc[:ip_allocation_policy] = MU::Cloud::Google.container(:IpAllocationPolicy).new( + use_ip_aliases: true + ) + end + + if @config['ip_aliases'] or @config['custom_subnet'] or + @config['services_ip_block'] or @config['services_ip_block_name'] or + @config['pod_ip_block'] or @config['pod_ip_block_name'] or + @config['tpu_ip_block'] + alloc_desc = { :use_ip_aliases => @config['ip_aliases'] } + + if @config['custom_subnet'] + alloc_desc[:create_subnetwork] = true + alloc_desc[:subnetwork_name] = if @config['custom_subnet']['name'] + @config['custom_subnet']['name'] + else + @mu_name.downcase + end + + if @config['custom_subnet']['node_ip_block'] + alloc_desc[:node_ipv4_cidr_block] = @config['custom_subnet']['node_ip_block'] + end + else + if @config['pod_ip_block_name'] + alloc_desc[:cluster_secondary_range_name] = @config['pod_ip_block_name'] + end + if @config['services_ip_block_name'] + alloc_desc[:services_secondary_range_name] = @config['services_ip_block_name'] + end + end + + if @config['services_ip_block'] + alloc_desc[:services_ipv4_cidr_block] = @config['services_ip_block'] + end + if @config['tpu_ip_block'] + alloc_desc[:tpu_ipv4_cidr_block] = @config['tpu_ip_block'] + end + if @config['pod_ip_block'] + alloc_desc[:cluster_ipv4_cidr_block] = @config['pod_ip_block'] + end + + desc[:ip_allocation_policy] = MU::Cloud::Google.container(:IpAllocationPolicy).new(alloc_desc) + pp alloc_desc + end + + if @config['authorized_networks'] and @config['authorized_networks'].size > 0 + desc[:master_authorized_networks_config] = MU::Cloud::Google.container(:MasterAuthorizedNetworksConfig).new( + enabled: true, + cidr_blocks: @config['authorized_networks'].map { |n| + MU::Cloud::Google.container(:CidrBlock).new( + cidr_block: n['ip_block'], + display_name: n['label'] + ) + } + ) + end + + if @config['kubernetes'] and @config['kubernetes']['max_pods'] and + @config['ip_aliases'] + desc[:default_max_pods_constraint] = MU::Cloud::Google.container(:MaxPodsConstraint).new( + max_pods_per_node: @config['kubernetes']['max_pods'] + ) + end + requestobj = MU::Cloud::Google.container(:CreateClusterRequest).new( - :cluster => MU::Cloud::Google.container(:Cluster).new(desc) + :cluster => MU::Cloud::Google.container(:Cluster).new(desc), ) - MU.log "Creating GKE cluster #{@mu_name.downcase}", details: desc - pp @vpc.subnets.map { |x| x.config['name'] } - pp requestobj - cluster = MU::Cloud::Google.container(credentials: @config['credentials']).create_cluster( - @config['project'], - @config['availability_zone'], + MU.log "Creating GKE cluster #{@mu_name.downcase}", details: requestobj + @config['master_az'] = @config['region'] + parent_arg = "projects/"+@config['project']+"/locations/"+@config['master_az'] + + cluster = MU::Cloud::Google.container(credentials: @config['credentials']).create_project_location_cluster( + parent_arg, requestobj ) + @cloud_id = parent_arg+"/clusters/"+@mu_name.downcase resp = nil begin - resp = MU::Cloud::Google.container(credentials: @config['credentials']).get_zone_cluster(@config["project"], @config['availability_zone'], @mu_name.downcase) + resp = MU::Cloud::Google.container(credentials: @config['credentials']).get_project_location_cluster(@cloud_id) + if resp.status == "ERROR" + MU.log "GKE cluster #{@cloud_id} failed", MU::ERR, details: resp.status_message + raise MuError, "GKE cluster #{@cloud_id} failed: #{resp.status_message}" + end sleep 30 if resp.status != "RUNNING" end while resp.nil? or resp.status != "RUNNING" -# labelCluster # XXX need newer API release - @cloud_id = @mu_name.downcase -# XXX wait until the thing is ready + writeKubeConfig + + end + + + # Called automatically by {MU::Deploy#createResources} + def groom + labelCluster + + me = cloud_desc + + parent_arg = "projects/"+@config['project']+"/locations/"+me.location + + # Enable/disable basic auth + authcfg = {} + action = nil + if @config['master_user'] and (me.master_auth.username != @config['master_user'] or !me.master_auth.password) + authcfg[:username] = @config['master_user'] + authcfg[:password] = Password.pronounceable(16..18) + MU.log "Enabling basic auth for GKE cluster #{@mu_name.downcase}", MU::NOTICE, details: authcfg + elsif !@config['master_user'] and me.master_auth.username + authcfg[:username] = "" + MU.log "Disabling basic auth for GKE cluster #{@mu_name.downcase}", MU::NOTICE + end + if authcfg.size > 0 + MU::Cloud::Google.container(credentials: @config['credentials']).set_project_location_cluster_master_auth( + @cloud_id, + MU::Cloud::Google.container(:SetMasterAuthRequest).new( + name: @cloud_id, + action: "SET_USERNAME", + update: MU::Cloud::Google.container(:MasterAuth).new( + authcfg + ) + ) + ) + me = cloud_desc(use_cache: false) + end + + # Now go through all the things that use update_project_location_cluster + updates = [] + + locations = if @config['availability_zone'] + [@config['availability_zone']] + else + MU::Cloud::Google.listAZs(@config['region']) + end + if me.locations != locations + updates << { :desired_locations => locations } + end + + if @config['min_size'] and @config['max_size'] and + (me.node_pools.first.autoscaling.min_node_count != @config['min_size'] or + me.node_pools.first.autoscaling.max_node_count != @config['max_size']) + updates << { + :desired_node_pool_autoscaling => MU::Cloud::Google.container(:NodePoolAutoscaling).new( + enabled: true, + max_node_count: @config['max_size'], + min_node_count: @config['min_size'] + ) + } + end + + if @config['authorized_networks'] and @config['authorized_networks'].size > 0 + desired = @config['authorized_networks'].map { |n| + MU::Cloud::Google.container(:CidrBlock).new( + cidr_block: n['ip_block'], + display_name: n['label'] + ) + } + if !me.master_authorized_networks_config or + !me.master_authorized_networks_config.enabled or + !me.master_authorized_networks_config.cidr_blocks or + me.master_authorized_networks_config.cidr_blocks.map {|n| n.cidr_block+n.display_name }.sort != desired.map {|n| n.cidr_block+n.display_name }.sort + updates << { :desired_master_authorized_networks_config => MU::Cloud::Google.container(:MasterAuthorizedNetworksConfig).new( + enabled: true, + cidr_blocks: desired + )} + end + elsif me.master_authorized_networks_config and + me.master_authorized_networks_config.enabled + updates << { :desired_master_authorized_networks_config => MU::Cloud::Google.container(:MasterAuthorizedNetworksConfig).new( + enabled: false + )} + end + + if @config['log_facility'] == "kubernetes" and me.logging_service != "logging.googleapis.com/kubernetes" + updates << { + :desired_logging_service => "logging.googleapis.com/kubernetes", + :desired_monitoring_service => "monitoring.googleapis.com/kubernetes" + } + elsif @config['log_facility'] == "basic" and me.logging_service != "logging.googleapis.com" + updates << { + :desired_logging_service => "logging.googleapis.com", + :desired_monitoring_service => "monitoring.googleapis.com" + } + elsif @config['log_facility'] == "none" and me.logging_service != "none" + updates << { + :desired_logging_service => "none", + :desired_monitoring_service => "none" + } + end + + if @config['kubernetes'] + if (me.addons_config.horizontal_pod_autoscaling.disabled and @config['kubernetes']['horizontal_pod_autoscaling']) or + (!me.addons_config.horizontal_pod_autoscaling and !@config['kubernetes']['horizontal_pod_autoscaling']) or + (me.addons_config.http_load_balancing.disabled and @config['kubernetes']['http_load_balancing']) or + (!me.addons_config.http_load_balancing and !@config['kubernetes']['http_load_balancing']) or + (me.addons_config.kubernetes_dashboard.disabled and @config['kubernetes']['dashboard']) or + (!me.addons_config.kubernetes_dashboard and !@config['kubernetes']['dashboard']) or + (me.addons_config.network_policy_config.disabled and @config['kubernetes']['network_policy_addon']) or + (!me.addons_config.network_policy_config and !@config['kubernetes']['network_policy_addon']) + updates << { :desired_addons_config => MU::Cloud::Google.container(:AddonsConfig).new( + horizontal_pod_autoscaling: MU::Cloud::Google.container(:HorizontalPodAutoscaling).new( + disabled: !@config['kubernetes']['horizontal_pod_autoscaling'] + ), + http_load_balancing: MU::Cloud::Google.container(:HttpLoadBalancing).new( + disabled: !@config['kubernetes']['http_load_balancing'] + ), + kubernetes_dashboard: MU::Cloud::Google.container(:KubernetesDashboard).new( + disabled: !@config['kubernetes']['dashboard'] + ), + network_policy_config: MU::Cloud::Google.container(:NetworkPolicyConfig).new( + disabled: !@config['kubernetes']['network_policy_addon'] + ) + )} + end + end + + if @config['kubernetes'] and @config['kubernetes']['version'] + if MU.version_sort(@config['kubernetes']['version'], me.current_master_version) > 0 + updates << { :desired_master_version => @config['kubernetes']['version'] } + end + end + + if @config['kubernetes'] and @config['kubernetes']['nodeversion'] + if MU.version_sort(@config['kubernetes']['nodeversion'], me.current_node_version) > 0 + updates << { :desired_node_version => @config['kubernetes']['nodeversion'] } + end + end + + if updates.size > 0 + updates.each { |mapping| + requestobj = MU::Cloud::Google.container(:UpdateClusterRequest).new( + :name => @cloud_id, + :update => MU::Cloud::Google.container(:ClusterUpdate).new( + mapping + ) + ) + MU.log "Updating GKE Cluster #{@mu_name.downcase}", MU::NOTICE, details: mapping + begin + MU::Cloud::Google.container(credentials: @config['credentials']).update_project_location_cluster( + @cloud_id, + requestobj + ) + rescue ::Google::Apis::ClientError => e + MU.log e.message, MU::WARN + end + } + me = cloud_desc(use_cache: false) + end + + if @config['preferred_maintenance_window'] and + (!me.maintenance_policy.window or + !me.maintenance_policy.window.daily_maintenance_window or + me.maintenance_policy.window.daily_maintenance_window.start_time != @config['preferred_maintenance_window']) + MU.log "Setting GKE Cluster #{@mu_name.downcase} maintenance time to #{@config['preferred_maintenance_window']}", MU::NOTICE + MU::Cloud::Google.container(credentials: @config['credentials']).set_project_location_cluster_maintenance_policy( + @cloud_id, + MU::Cloud::Google.container(:SetMaintenancePolicyRequest).new( + maintenance_policy: MU::Cloud::Google.container(:MaintenancePolicy).new( + window: MU::Cloud::Google.container(:MaintenanceWindow).new( + daily_maintenance_window: MU::Cloud::Google.container(:DailyMaintenanceWindow).new( + start_time: @config['preferred_maintenance_window'] + ) + ) + ) + ) + ) + elsif !@config['preferred_maintenance_window'] and me.maintenance_policy.window + MU.log "Unsetting GKE Cluster #{@mu_name.downcase} maintenance time to #{@config['preferred_maintenance_window']}", MU::NOTICE + MU::Cloud::Google.container(credentials: @config['credentials']).set_project_location_cluster_maintenance_policy( + @cloud_id, + nil + ) + end + + + kube_conf = writeKubeConfig + + if @config['kubernetes_resources'] + MU::Master.applyKubernetesResources( + @config['name'], + @config['kubernetes_resources'], + kubeconfig: kube_conf, + outputdir: @deploy.deploy_dir + ) + end + + MU.log %Q{How to interact with your GKE cluster\nkubectl --kubeconfig "#{kube_conf}" get events --all-namespaces\nkubectl --kubeconfig "#{kube_conf}" get all\nkubectl --kubeconfig "#{kube_conf}" create -f some_k8s_deploy.yml\nkubectl --kubeconfig "#{kube_conf}" get nodes}, MU::SUMMARY end + # Locate an existing ContainerCluster or ContainerClusters and return an array containing matching GCP resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @param flags [Hash]: Optional flags # @return [Array>]: The cloud provider's complete descriptions of matching ContainerClusters - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, flags: {}, credentials: nil) - flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) + def self.find(**args) + args[:project] ||= args[:habitat] + args[:project] ||= MU::Cloud::Google.defaultProject(args[:credentials]) + location = args[:region] || args[:availability_zone] || "-" + + found = {} + + if args[:cloud_id] + resp = begin + MU::Cloud::Google.container(credentials: args[:credentials]).get_project_location_cluster(args[:cloud_id]) + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/forbidden:/) + end + found[args[:cloud_id]] = resp if resp + else + resp = begin + MU::Cloud::Google.container(credentials: args[:credentials]).list_project_location_clusters("projects/#{args[:project]}/locations/#{location}") + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/forbidden:/) + end + if resp and resp.clusters and !resp.clusters.empty? + resp.clusters.each { |c| + found[c.self_link.sub(/.*?\/projects\//, 'projects/')] = c + } + end + end + + found end - # Called automatically by {MU::Deploy#createResources} - def groom - deploydata = describe[2] - @config['availability_zone'] ||= deploydata['zone'] - resp = MU::Cloud::Google.container(credentials: @config['credentials']).get_zone_cluster(@config["project"], @config['availability_zone'], @mu_name.downcase) -# pp resp - -# labelCluster # XXX need newer API release - - # desired_*: - # addons_config - # image_type - # locations - # master_authorized_networks_config - # master_version - # monitoring_service - # node_pool_autoscaling - # node_pool_id - # node_version -# update = { - -# } -# pp update -# requestobj = MU::Cloud::Google.container(:UpdateClusterRequest).new( -# :cluster => MU::Cloud::Google.container(:ClusterUpdate).new(update) -# ) - # XXX do all the kubernetes stuff like we do in AWS + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + def toKitten(rootparent: nil, billing: nil, habitats: nil) + + bok = { + "cloud" => "Google", + "project" => @config['project'], + "credentials" => @config['credentials'], + "cloud_id" => @cloud_id, + "name" => cloud_desc.name.dup + } + + bok['region'] = cloud_desc.location.sub(/\-[a-z]$/, "") + if cloud_desc.locations.size == 1 + bok['availability_zone'] = cloud_desc.locations.first + end + bok["instance_count"] = cloud_desc.current_node_count + cloud_desc.network_config.network.match(/^projects\/(.*?)\/.*?\/networks\/([^\/]+)(?:$|\/)/) + vpc_proj = Regexp.last_match[1] + vpc_id = Regexp.last_match[2] + + bok['vpc'] = MU::Config::Ref.get( + id: vpc_id, + cloud: "Google", + habitat: MU::Config::Ref.get( + id: vpc_proj, + cloud: "Google", + credentials: @credentials, + type: "habitats" + ), + credentials: @config['credentials'], + type: "vpcs" + ) + + + bok['kubernetes'] = { + "version" => cloud_desc.current_master_version, + "nodeversion" => cloud_desc.current_node_version + } + if cloud_desc.default_max_pods_constraint and + cloud_desc.default_max_pods_constraint.max_pods_per_node + bok['kubernetes']['max_pods'] = cloud_desc.default_max_pods_constraint.max_pods_per_node + end + + if cloud_desc.addons_config.horizontal_pod_autoscaling and + cloud_desc.addons_config.horizontal_pod_autoscaling.disabled + bok['kubernetes']['horizontal_pod_autoscaling'] = false + end + if cloud_desc.addons_config.http_load_balancing and + cloud_desc.addons_config.http_load_balancing.disabled + bok['kubernetes']['http_load_balancing'] = false + end + if !cloud_desc.addons_config.kubernetes_dashboard or + !cloud_desc.addons_config.kubernetes_dashboard.disabled + bok['kubernetes']['dashboard'] = true + end + if !cloud_desc.addons_config.network_policy_config or + !cloud_desc.addons_config.network_policy_config.disabled + bok['kubernetes']['network_policy_addon'] = true + end + + if cloud_desc.ip_allocation_policy.use_ip_aliases + bok['ip_aliases'] = true + end + if cloud_desc.ip_allocation_policy.cluster_ipv4_cidr_block + bok['pod_ip_block'] = cloud_desc.ip_allocation_policy.cluster_ipv4_cidr_block + end + if cloud_desc.ip_allocation_policy.services_ipv4_cidr_block + bok['services_ip_block'] = cloud_desc.ip_allocation_policy.services_ipv4_cidr_block + end + + if cloud_desc.ip_allocation_policy.create_subnetwork + bok['custom_subnet'] = { + "name" => (cloud_desc.ip_allocation_policy.subnetwork_name || cloud_desc.subnetwork) + } + if cloud_desc.ip_allocation_policy.node_ipv4_cidr_block + bok['custom_subnet']['node_ip_block'] = cloud_desc.ip_allocation_policy.node_ipv4_cidr_block + end + end + + bok['log_facility'] = if cloud_desc.logging_service == "logging.googleapis.com" + "basic" + elsif cloud_desc.logging_service == "logging.googleapis.com/kubernetes" + "kubernetes" + else + "none" + end + + if cloud_desc.master_auth and cloud_desc.master_auth.username + bok['master_user'] = cloud_desc.master_auth.username + end + + if cloud_desc.maintenance_policy and + cloud_desc.maintenance_policy.window and + cloud_desc.maintenance_policy.window.daily_maintenance_window and + cloud_desc.maintenance_policy.window.daily_maintenance_window.start_time + bok['preferred_maintenance_window'] = cloud_desc.maintenance_policy.window.daily_maintenance_window.start_time + end + + if cloud_desc.enable_tpu + bok['tpu'] = true + end + if cloud_desc.enable_kubernetes_alpha + bok['kubernetes'] ||= {} + bok['kubernetes']['alpha'] = true + end + + if cloud_desc.node_pools and cloud_desc.node_pools.size > 0 + pool = cloud_desc.node_pools.first # we don't really support multiples atm + bok["instance_type"] = pool.config.machine_type + bok["instance_count"] = pool.initial_node_count + bok['scopes'] = pool.config.oauth_scopes + if pool.config.metadata + bok["metadata"] = pool.config.metadata.keys.map { |k| + { "key" => k, "value" => pool.config.metadata[k] } + } + end + if pool.autoscaling and pool.autoscaling.enabled + bok['max_size'] = pool.autoscaling.max_node_count + bok['min_size'] = pool.autoscaling.min_node_count + end + bok['auto_repair'] = false + bok['auto_upgrade'] = false + if pool.management + bok['auto_repair'] = true if pool.management.auto_repair + bok['auto_upgrade'] = true if pool.management.auto_upgrade + end + [:local_ssd_count, :min_cpu_platform, :image_type, :disk_size_gb, :preemptible, :service_account].each { |field| + if pool.config.respond_to?(field) + bok[field.to_s] = pool.config.method(field).call + bok.delete(field.to_s) if bok[field.to_s].nil? + end + } + else + bok["instance_type"] = cloud_desc.node_config.machine_type + bok['scopes'] = cloud_desc.node_config.oauth_scopes + if cloud_desc.node_config.metadata + bok["metadata"] = cloud_desc.node_config.metadata.keys.map { |k| + { "key" => k, "value" => pool.config.metadata[k] } + } + end + [:local_ssd_count, :min_cpu_platform, :image_type, :disk_size_gb, :preemptible, :service_account].each { |field| + if cloud_desc.node_config.respond_to?(field) + bok[field.to_s] = cloud_desc.node_config.method(field).call + bok.delete(field.to_s) if bok[field.to_s].nil? + end + } + end + + if bok['service_account'] + found = MU::Cloud::Google::User.find( + credentials: bok['credentials'], + project: bok['project'], + cloud_id: bok['service_account'] + ) + if found and found.size == 1 + sa = found.values.first + # Ignore generic Mu service accounts + if cloud_desc.resource_labels and + cloud_desc.resource_labels["mu-id"] and + sa.description and + cloud_desc.resource_labels["mu-id"].downcase == sa.description.downcase + bok.delete("service_account") + else + bok['service_account'] = MU::Config::Ref.get( + id: found.values.first.name, + cloud: "Google", + credentials: @config['credentials'], + type: "users" + ) + end + else + bok.delete("service_account") + end + end + + if cloud_desc.private_cluster_config + if cloud_desc.private_cluster_config.enable_private_nodes? + bok["private_cluster"] ||= {} + bok["private_cluster"]["private_nodes"] = true + end + if cloud_desc.private_cluster_config.enable_private_endpoint? + bok["private_cluster"] ||= {} + bok["private_cluster"]["private_master"] = true + end + if cloud_desc.private_cluster_config.master_ipv4_cidr_block + bok["private_cluster"] ||= {} + bok["private_cluster"]["master_ip_block"] = cloud_desc.private_cluster_config.master_ipv4_cidr_block + end + end + + if cloud_desc.master_authorized_networks_config and + cloud_desc.master_authorized_networks_config.cidr_blocks and + cloud_desc.master_authorized_networks_config.cidr_blocks.size > 0 + bok['authorized_networks'] = [] + cloud_desc.master_authorized_networks_config.cidr_blocks.each { |c| + bok['authorized_networks'] << { + "ip_block" => c.cidr_block, + "label" => c.display_name + } + } + end + + bok end + # Register a description of this cluster instance with this deployment's metadata. def notify - desc = MU.structToHash(MU::Cloud::Google.container(credentials: @config['credentials']).get_zone_cluster(@config["project"], @config['availability_zone'], @mu_name.downcase)) + resp = MU::Cloud::Google.container(credentials: @config['credentials']).get_project_location_cluster(@cloud_id) + desc = MU.structToHash(resp) desc["project"] = @config['project'] desc["cloud_id"] = @cloud_id desc["project_id"] = @project_id @@ -205,7 +729,7 @@ def self.isGlobal? # Denote whether this resource implementation is experiment, ready for # testing, or ready for production use. def self.quality - MU::Cloud::ALPHA + MU::Cloud::RELEASE end # Called by {MU::Cleanup}. Locates resources that were created by the @@ -218,35 +742,51 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent skipsnapshots = flags["skipsnapshots"] flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) + return if !MU::Cloud::Google::Habitat.isLive?(flags["project"], credentials) + clusters = [] + + # Make sure we catch regional *and* zone clusters + found = MU::Cloud::Google.container(credentials: credentials).list_project_location_clusters("projects/#{flags['project']}/locations/#{region}") + clusters.concat(found.clusters) if found and found.clusters MU::Cloud::Google.listAZs(region).each { |az| - found = MU::Cloud::Google.container(credentials: credentials).list_zone_clusters(flags["project"], az) - if found and found.clusters - found.clusters.each { |cluster| + found = MU::Cloud::Google.container(credentials: credentials).list_project_location_clusters("projects/#{flags['project']}/locations/#{az}") + clusters.concat(found.clusters) if found and found.clusters + } - if !cluster.name.match(/^#{Regexp.quote(MU.deploy_id)}\-/i) and - cluster.resource_labels['mu-id'] != MU.deploy_id.downcase - next - end - MU.log "Deleting GKE cluster #{cluster.name}" - if !noop - MU::Cloud::Google.container(credentials: credentials).delete_zone_cluster(flags["project"], az, cluster.name) - begin - MU::Cloud::Google.container(credentials: credentials).get_zone_cluster(flags["project"], az, cluster.name) - sleep 60 - rescue ::Google::Apis::ClientError => e - if e.message.match(/is currently creating cluster/) - sleep 60 - retry - elsif !e.message.match(/notFound:/) - raise e - else - break - end - end while true + clusters.uniq.each { |cluster| + if !cluster.resource_labels or ( + !cluster.name.match(/^#{Regexp.quote(MU.deploy_id)}\-/i) and + cluster.resource_labels['mu-id'] != MU.deploy_id.downcase + ) + next + end + MU.log "Deleting GKE cluster #{cluster.name}" + if !noop + cloud_id = cluster.self_link.sub(/.*?\/projects\//, 'projects/') + retries = 0 + begin + MU::Cloud::Google.container(credentials: credentials).delete_project_location_cluster(cloud_id) + MU::Cloud::Google.container(credentials: credentials).get_project_location_cluster(cloud_id) + sleep 60 + rescue ::Google::Apis::ClientError => e + if e.message.match(/notFound: /) + MU.log cloud_id, MU::WARN, details: e.inspect + break + elsif e.message.match(/failedPrecondition: /) + if (retries % 5) == 0 + MU.log "Waiting to delete GKE cluster #{cluster.name}: #{e.message}", MU::NOTICE + end + sleep 60 + retries += 1 + retry + else + MU.log cloud_id, MU::WARN, details: e.inspect + raise e end - } + end while true end } + end # Cloud-specific configuration properties. @@ -254,16 +794,104 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource def self.schema(config) toplevel_required = [] + gke_defaults = defaults schema = { + "auto_upgrade" => { + "type" => "boolean", + "description" => "Automatically upgrade worker nodes during maintenance windows", + "default" => true + }, + "auto_repair" => { + "type" => "boolean", + "description" => "Automatically replace worker nodes which fail health checks", + "default" => true + }, "local_ssd_count" => { "type" => "integer", "description" => "The number of local SSD disks to be attached to workers. See https://cloud.google.com/compute/docs/disks/local-ssd#local_ssd_limits" }, + "ssh_user" => MU::Cloud::Google::Server.schema(config)[1]["ssh_user"], + "metadata" => MU::Cloud::Google::Server.schema(config)[1]["metadata"], + "service_account" => MU::Cloud::Google::Server.schema(config)[1]["service_account"], + "scopes" => MU::Cloud::Google::Server.schema(config)[1]["scopes"], + "private_cluster" => { + "description" => "Set a GKE cluster to be private, that is segregated into its own hidden VPC.", + "type" => "object", + "properties" => { + "private_nodes" => { + "type" => "boolean", + "default" => true, + "description" => "Whether GKE worker nodes have internal IP addresses only." + }, + "private_master" => { + "type" => "boolean", + "default" => false, + "description" => "Whether the GKE Kubernetes master's internal IP address is used as the cluster endpoint." + }, + "master_ip_block" => { + "type" => "string", + "pattern" => MU::Config::CIDR_PATTERN, + "default" => "172.20.0.0/28", + "description" => "The private IP address range to use for the GKE master's network" + } + } + }, + "custom_subnet" => { + "type" => "object", + "description" => "If set, GKE will create a new subnetwork specifically for this cluster", + "properties" => { + "name" => { + "type" => "string", + "description" => "Set a custom name for the generated subnet" + }, + "node_ip_block" => { + "type" => "string", + "pattern" => MU::Config::CIDR_PATTERN, + "description" => "The IP address range of the worker nodes in this cluster, in CIDR notation" + } + } + }, + "pod_ip_block" => { + "type" => "string", + "pattern" => MU::Config::CIDR_PATTERN, + "description" => "The IP address range of the container pods in this cluster, in CIDR notation" + }, + "pod_ip_block_name" => { + "type" => "string", + "description" => "The name of the secondary range to be used for the pod CIDR block" + }, + "services_ip_block" => { + "type" => "string", + "pattern" => MU::Config::CIDR_PATTERN, + "description" => "The IP address range of the services in this cluster, in CIDR notation" + }, + "services_ip_block_name" => { + "type" => "string", + "description" => "The name of the secondary range to be used for the services CIDR block" + }, + "ip_aliases" => { + "type" => "boolean", + "description" => "Whether alias IPs will be used for pod IPs in the cluster. Will be automatically enabled for functionality, such as +private_cluster+, which requires it." + }, + "tpu_ip_block" => { + "type" => "string", + "pattern" => MU::Config::CIDR_PATTERN, + "description" => "The IP address range of any Cloud TPUs in this cluster, in CIDR notation" + }, "disk_size_gb" => { "type" => "integer", "description" => "Size of the disk attached to each worker, specified in GB. The smallest allowed disk size is 10GB", "default" => 100 }, + "min_size" => { + "description" => "In GKE, this is the minimum number of nodes *per availability zone*, when scaling is enabled. Setting +min_size+ and +max_size+ enables scaling." + }, + "max_size" => { + "description" => "In GKE, this is the maximum number of nodes *per availability zone*, when scaling is enabled. Setting +min_size+ and +max_size+ enables scaling." + }, + "instance_count" => { + "description" => "In GKE, this value is ignored if +min_size+ and +max_size+ are set." + }, "min_cpu_platform" => { "type" => "string", "description" => "Minimum CPU platform to be used by workers. The instances may be scheduled on the specified or newer CPU platform. Applicable values are the friendly names of CPU platforms, such as minCpuPlatform: 'Intel Haswell' or minCpuPlatform: 'Intel Sandy Bridge'." @@ -275,7 +903,95 @@ def self.schema(config) }, "image_type" => { "type" => "string", - "description" => "The image type to use for workers. Note that for a given image type, the latest version of it will be used." + "enum" => gke_defaults ? gke_defaults.valid_image_types : ["COS"], + "description" => "The image type to use for workers. Note that for a given image type, the latest version of it will be used.", + "default" => gke_defaults ? gke_defaults.default_image_type : "COS" + }, + "availability_zone" => { + "type" => "string", + "description" => "Target a specific availability zone for this cluster" + }, + "preferred_maintenance_window" => { + "type" => "string", + "description" => "The preferred daily time to perform node maintenance. Time format should be in [RFC3339](http://www.ietf.org/rfc/rfc3339.txt) format +HH:MM+ GMT.", + "pattern" => '^\d\d:\d\d$' + }, + "kubernetes" => { + "description" => "Kubernetes-specific options", + "properties" => { + "version" => { + "type" => "string" + }, + "nodeversion" => { + "type" => "string", + "description" => "The version of Kubernetes to install on GKE worker nodes." + }, + "alpha" => { + "type" => "boolean", + "default" => false, + "description" => "Enable alpha-quality Kubernetes features on this cluster" + }, + "dashboard" => { + "type" => "boolean", + "default" => false, + "description" => "Enable the Kubernetes Dashboard" + }, + "horizontal_pod_autoscaling" => { + "type" => "boolean", + "default" => true, + "description" => "Increases or decreases the number of replica pods a replication controller has based on the resource usage of the existing pods." + }, + "http_load_balancing" => { + "type" => "boolean", + "default" => true, + "description" => "HTTP (L7) load balancing controller addon, which makes it easy to set up HTTP load balancers for services in a cluster." + }, + "network_policy_addon" => { + "type" => "boolean", + "default" => false, + "description" => "Enable the Network Policy addon" + } + } + }, + "pod_ip_range" => { + "type" => "string", + "pattern" => MU::Config::CIDR_PATTERN, + "description" => "The IP address range of the container pods in this cluster, in CIDR notation" + }, + "tpu" => { + "type" => "boolean", + "default" => false, + "description" => "Enable the ability to use Cloud TPUs in this cluster." + }, + "log_facility" => { + "type" => "string", + "default" => "kubernetes", + "description" => "The +logging.googleapis.com+ and +monitoring.googleapis.com+ facilities that this cluster should use to write logs and metrics.", + "enum" => ["basic", "kubernetes", "none"] + }, + "master_user" => { + "type" => "string", + "description" => "Enables Basic Auth for a GKE cluster with string as the master username" + }, + "authorized_networks" => { + "type" => "array", + "items" => { + "description" => "GKE's Master authorized networks functionality", + "type" => "object", + "ip_block" => { + "type" => "string", + "description" => "CIDR block to allow", + "pattern" => MU::Config::CIDR_PATTERN, + }, + "label" =>{ + "description" => "Label for this CIDR block", + "type" => "string", + } + } + }, + "master_az" => { + "type" => "string", + "description" => "Target a specific Availability Zone for the GKE master. If not set, we will choose one which has the most current versions of Kubernetes available." } } [toplevel_required, schema] @@ -287,11 +1003,156 @@ def self.schema(config) # @return [Boolean]: True if validation succeeded, False otherwise def self.validateConfig(cluster, configurator) ok = true -# XXX validate k8s versions (master and node) -# XXX validate image types -# MU::Cloud::Google.container.get_project_zone_serverconfig(@config["project"], @config['availability_zone']) + cluster['project'] ||= MU::Cloud::Google.defaultProject(cluster['credentials']) + + cluster['master_az'] ||= cluster['availability_zone'] if cluster['availability_zone'] + + if cluster['private_cluster'] or cluster['custom_subnet'] or + cluster['services_ip_block'] or cluster['services_ip_block_name'] or + cluster['pod_ip_block'] or cluster['pod_ip_block_name'] or + cluster['tpu_ip_block'] + cluster['ip_aliases'] = true + end + + if cluster['service_account'] + cluster['service_account']['cloud'] = "Google" + cluster['service_account']['habitat'] ||= MU::Config::Ref.get( + id: cluster['project'], + cloud: "Google", + credentials: cluster['credentials'], + type: "habitats" + ) + if cluster['service_account']['name'] and + !cluster['service_account']['id'] + cluster['dependencies'] ||= [] + cluster['dependencies'] << { + "type" => "user", + "name" => cluster['service_account']['name'] + } + end + found = MU::Config::Ref.get(cluster['service_account']) + # XXX verify that found.kitten fails when it's supposed to + if cluster['service_account']['id'] and !found.kitten + MU.log "GKE cluster #{cluster['name']} failed to locate service account #{cluster['service_account']} in project #{cluster['project']}", MU::ERR + ok = false + end + else + user = { + "name" => cluster['name'], + "cloud" => "Google", + "project" => cluster["project"], + "credentials" => cluster["credentials"], + "type" => "service" + } + configurator.insertKitten(user, "users", true) + cluster['dependencies'] ||= [] + cluster['service_account'] = MU::Config::Ref.get( + type: "users", + cloud: "Google", + name: cluster["name"], + project: cluster["project"], + credentials: cluster["credentials"] + ) + cluster['dependencies'] << { + "type" => "user", + "name" => cluster["name"] + } + end + + if cluster['dependencies'] + cluster['dependencies'].each { |dep| + if dep['type'] == "vpc" + dep['phase'] = "groom" + end + } + end + + if (cluster['pod_ip_block_name'] or cluster['services_ip_block_name']) and + cluster['custom_subnet'] + MU.log "GKE cluster #{cluster['name']} cannot specify pod_ip_block_name or services_ip_block_name when using a custom subnet", MU::ERR + ok = false + end + + # If we've enabled master authorized networks, make sure our Mu + # Master is one of the things allowed in. + if cluster['authorized_networks'] + found_me = false + my_cidr = NetAddr::IPv4.parse(MU.mu_public_ip) + cluster['authorized_networks'].each { |block| + cidr_obj = NetAddr::IPv4Net.parse(block['ip_block']) + if cidr_obj.contains(my_cidr) + found_me = true + break + end + } + if !found_me + cluster['authorized_networks'] << { + "ip_block" => MU.mu_public_ip+"/32", + "label" => "Mu Master #{$MU_CFG['hostname']}" + } + end + end + + master_versions = defaults(az: cluster['master_az']).valid_master_versions.sort { |a, b| MU.version_sort(a, b) } + if cluster['kubernetes'] and cluster['kubernetes']['version'] + if cluster['kubernetes']['version'] == "latest" + cluster['kubernetes']['version'] = master_versions.last + elsif !master_versions.include?(cluster['kubernetes']['version']) + match = false + master_versions.each { |v| + if v.match(/^#{Regexp.quote(cluster['kubernetes']['version'])}/) + match = true + break + end + } + if !match + MU.log "No version matching #{cluster['kubernetes']['version']} available, will try floating minor revision", MU::WARN + cluster['kubernetes']['version'].sub!(/^(\d+\.\d+\.).*/i, '\1') + master_versions.each { |v| + if v.match(/^#{Regexp.quote(cluster['kubernetes']['version'])}/) + match = true + break + end + } + if !match + MU.log "Failed to find a GKE master version matching #{cluster['kubernetes']['version']} among available versions in #{cluster['master_az'] || cluster['region']}.", MU::ERR, details: master_versions + ok = false + end + end + end + end + + node_versions = defaults(az: cluster['master_az']).valid_node_versions.sort { |a, b| MU.version_sort(a, b) } + + if cluster['kubernetes'] and cluster['kubernetes']['nodeversion'] + if cluster['kubernetes']['nodeversion'] == "latest" + cluster['kubernetes']['nodeversion'] = node_versions.last + elsif !node_versions.include?(cluster['kubernetes']['nodeversion']) + match = false + node_versions.each { |v| + if v.match(/^#{Regexp.quote(cluster['kubernetes']['nodeversion'])}/) + match = true + break + end + } + if !match + MU.log "No version matching #{cluster['kubernetes']['nodeversion']} available, will try floating minor revision", MU::WARN + cluster['kubernetes']['nodeversion'].sub!(/^(\d+\.\d+\.).*/i, '\1') + node_versions.each { |v| + if v.match(/^#{Regexp.quote(cluster['kubernetes']['nodeversion'])}/) + match = true + break + end + } + if !match + MU.log "Failed to find a GKE node version matching #{cluster['kubernetes']['nodeversion']} among available versions in #{cluster['master_az'] || cluster['region']}.", MU::ERR, details: node_versions + ok = false + end + end + end + end - cluster['instance_type'] = MU::Cloud::Google::Server.validateInstanceType(cluster["instance_type"], cluster["region"]) + cluster['instance_type'] = MU::Cloud::Google::Server.validateInstanceType(cluster["instance_type"], cluster["region"], project: cluster['project'], credentials: cluster['credentials']) ok = false if cluster['instance_type'].nil? ok @@ -299,19 +1160,121 @@ def self.validateConfig(cluster, configurator) private - def labelCluster - labels = {} - MU::MommaCat.listStandardTags.each_pair { |name, value| - if !value.nil? - labels[name.downcase] = value.downcase.gsub(/[^a-z0-9\-\_]/i, "_") + def node_desc + labels = Hash[@tags.keys.map { |k| + [k.downcase, @tags[k].downcase.gsub(/[^-_a-z0-9]/, '-')] } + ] + labels["name"] = MU::Cloud::Google.nameStr(@mu_name) + desc = { + :machine_type => @config['instance_type'], + :preemptible => @config['preemptible'], + :disk_size_gb => @config['disk_size_gb'], + :labels => labels, + :tags => [@mu_name.downcase], + :service_account => @service_acct.email, + :oauth_scopes => @config['scopes'] + } + desc[:metadata] = {} + deploykey = @config['ssh_user']+":"+@deploy.ssh_public_key + if @config['metadata'] + desc[:metadata] = Hash[@config['metadata'].map { |m| + [m["key"], m["value"]] + }] + end + if desc[:metadata]["ssh-keys"] + desc[:metadata]["ssh-keys"] += "\n"+deploykey + else + desc[:metadata]["ssh-keys"] = deploykey + end + [:local_ssd_count, :min_cpu_platform, :image_type].each { |field| + if @config[field.to_s] + desc[field] = @config[field.to_s] end } + desc + end + + def labelCluster + labels = Hash[@tags.keys.map { |k| + [k.downcase, @tags[k].downcase.gsub(/[^-_a-z0-9]/, '-')] } + ] labels["name"] = MU::Cloud::Google.nameStr(@mu_name) labelset = MU::Cloud::Google.container(:SetLabelsRequest).new( resource_labels: labels ) - MU::Cloud::Google.container(credentials: @config['credentials']).resource_project_zone_cluster_labels(@config["project"], @config['availability_zone'], @mu_name.downcase, labelset) + MU::Cloud::Google.container(credentials: @config['credentials']).set_project_location_cluster_resource_labels(@cloud_id, labelset) + end + + @@server_config = {} + def self.defaults(credentials = nil, az: nil) + az ||= MU::Cloud::Google.listAZs.sample + return nil if az.nil? + @@server_config[credentials] ||= {} + if @@server_config[credentials][az] + return @@server_config[credentials][az] + end + + parent_arg = "projects/"+MU::Cloud::Google.defaultProject(credentials)+"/locations/"+az + + @@server_config[credentials][az] = MU::Cloud::Google.container(credentials: credentials).get_project_location_server_config(parent_arg) + @@server_config[credentials][az] + end + + def writeKubeConfig + kube_conf = @deploy.deploy_dir+"/kubeconfig-#{@config['name']}" + client_binding = @deploy.deploy_dir+"/k8s-client-user-admin-binding.yaml" + @endpoint = "https://"+cloud_desc.endpoint + @cacert = cloud_desc.master_auth.cluster_ca_certificate + @cluster = cloud_desc.name + @clientcert = cloud_desc.master_auth.client_certificate + @clientkey = cloud_desc.master_auth.client_key + if cloud_desc.master_auth.username + @username = cloud_desc.master_auth.username + end + if cloud_desc.master_auth.password + @password = cloud_desc.master_auth.password + end + + kube = ERB.new(File.read(MU.myRoot+"/cookbooks/mu-tools/templates/default/kubeconfig-gke.erb")) + File.open(kube_conf, "w"){ |k| + k.puts kube.result(binding) + } + + # Take this opportunity to ensure that the 'client' service account + # used by certificate authentication exists and has appropriate + # privilege + if @username and @password + File.open(client_binding, "w"){ |k| + k.puts <<-EOF +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: client-binding + namespace: kube-system +roleRef: + kind: ClusterRole + name: cluster-admin + apiGroup: rbac.authorization.k8s.io +subjects: +- kind: User + name: client + namespace: kube-system + EOF + } + bind_cmd = %Q{#{MU::Master.kubectl} create serviceaccount client --namespace=kube-system --kubeconfig "#{kube_conf}" ; #{MU::Master.kubectl} --kubeconfig "#{kube_conf}" apply -f #{client_binding}} + MU.log bind_cmd + system(bind_cmd) + end + # unset the variables we set just for ERB + [:@endpoint, :@cacert, :@cluster, :@clientcert, :@clientkey, :@username, :@password].each { |var| + begin + remove_instance_variable(var) + rescue NameError + end + } + + kube_conf end end #class diff --git a/modules/mu/clouds/google/database.rb b/modules/mu/clouds/google/database.rb index 5c6345f84..27927fd96 100644 --- a/modules/mu/clouds/google/database.rb +++ b/modules/mu/clouds/google/database.rb @@ -17,47 +17,28 @@ class Cloud class Google # A database as configured in {MU::Config::BasketofKittens::databases} class Database < MU::Cloud::Database - @deploy = nil - @project_id = nil - @config = nil - attr_reader :mu_name - attr_reader :cloud_id - attr_reader :config - attr_reader :groomer - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::databases} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - # @mu_name = mu_name ? mu_name : @deploy.getResourceName(@config["name"]) + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super @config["groomer"] = MU::Config.defaultGroomer unless @config["groomer"] @groomclass = MU::Groomer.loadGroomer(@config["groomer"]) - if !mu_name.nil? - @mu_name = mu_name - @config['project'] ||= MU::Cloud::Google.defaultProject(@config['credentials']) - if !@project_id - project = MU::Cloud::Google.projectLookup(@config['project'], @deploy, sibling_only: true, raise_on_fail: false) - @project_id = project.nil? ? @config['project'] : project.cloudobj.cloud_id + @mu_name ||= + if @config and @config['engine'] and @config["engine"].match(/^sqlserver/) + @deploy.getResourceName(@config["name"], max_length: 15) + else + @deploy.getResourceName(@config["name"], max_length: 63) end - else - @mu_name ||= - if @config and @config['engine'] and @config["engine"].match(/^sqlserver/) - @deploy.getResourceName(@config["name"], max_length: 15) - else - @deploy.getResourceName(@config["name"], max_length: 63) - end - - @mu_name.gsub(/(--|-$)/i, "").gsub(/(_)/, "-").gsub!(/^[^a-z]/i, "") - end + + @mu_name.gsub(/(--|-$)/i, "").gsub(/(_)/, "-").gsub!(/^[^a-z]/i, "") end # Called automatically by {MU::Deploy#createResources} # @return [String]: The cloud provider's identifier for this database instance. def create - @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloudobj.cloud_id + @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloud_id labels = {} MU::MommaCat.listStandardTags.each_pair { |name, value| if !value.nil? @@ -85,19 +66,15 @@ def create end # Locate an existing Database or Databases and return an array containing matching GCP resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @param flags [Hash]: Optional flags # @return [Array>]: The cloud provider's complete descriptions of matching Databases - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, flags: {}, credentials: nil) - flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) + def self.find(**args) + args[:project] ||= args[:habitat] + args[:project] ||= MU::Cloud::Google.defaultProject(args[:credentials]) end # Called automatically by {MU::Deploy#createResources} def groom - @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloudobj.cloud_id + @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloud_id end # Register a description of this database instance with this deployment's metadata. @@ -134,13 +111,13 @@ def self.quality def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) skipsnapshots||= flags["skipsnapshots"] - instances = MU::Cloud::Google.sql(credentials: credentials).list_instances(flags['project'], filter: %Q{userLabels.mu-id:"#{MU.deploy_id.downcase}"}) - if instances and instances.items - instances.items.each { |instance| - MU.log "Deleting Cloud SQL instance #{instance.name}" - MU::Cloud::Google.sql(credentials: credentials).delete_instance(flags['project'], instance.name) if !noop - } - end +# instances = MU::Cloud::Google.sql(credentials: credentials).list_instances(flags['project'], filter: %Q{userLabels.mu-id:"#{MU.deploy_id.downcase}"}) +# if instances and instances.items +# instances.items.each { |instance| +# MU.log "Deleting Cloud SQL instance #{instance.name}" +# MU::Cloud::Google.sql(credentials: credentials).delete_instance(flags['project'], instance.name) if !noop +# } +# end end # Cloud-specific configuration properties. diff --git a/modules/mu/clouds/google/firewall_rule.rb b/modules/mu/clouds/google/firewall_rule.rb index 6265ae8f5..1adde7180 100644 --- a/modules/mu/clouds/google/firewall_rule.rb +++ b/modules/mu/clouds/google/firewall_rule.rb @@ -18,57 +18,49 @@ class Cloud class Google # A firewall ruleset as configured in {MU::Config::BasketofKittens::firewall_rules} class FirewallRule < MU::Cloud::FirewallRule - - @deploy = nil - @config = nil - @project_id = nil @admin_sgs = Hash.new @admin_sg_semaphore = Mutex.new - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::firewall_rules} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - if !mu_name.nil? - @mu_name = mu_name - # This is really a placeholder, since we "own" multiple rule sets - @cloud_id ||= MU::Cloud::Google.nameStr(@mu_name+"-ingress-allow") - @config['project'] ||= MU::Cloud::Google.defaultProject(@config['credentials']) - if !@project_id - project = MU::Cloud::Google.projectLookup(@config['project'], @deploy, sibling_only: true, raise_on_fail: false) - @project_id = project.nil? ? @config['project'] : project.cloudobj.cloud_id - end + # Firewall protocols supported by GCP as of early 2019 + PROTOS = ["udp", "tcp", "icmp", "esp", "ah", "sctp", "ipip"] + + # Our default subset of supported firewall protocols + STD_PROTOS = ["icmp", "tcp", "udp"] + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + + if !@vpc.nil? + @mu_name ||= @deploy.getResourceName(@config['name'], need_unique_string: true, max_length: 61) else - if !@vpc.nil? - @mu_name = @deploy.getResourceName(@config['name'], need_unique_string: true, max_length: 61) - else - @mu_name = @deploy.getResourceName(@config['name'], max_length: 61) - end + @mu_name ||= @deploy.getResourceName(@config['name'], max_length: 61) end - end attr_reader :rulesets # Called by {MU::Deploy#createResources} def create - @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloudobj.cloud_id + @cloud_id = @mu_name.downcase.gsub(/[^-a-z0-9]/, "-") - vpc_id = @vpc.cloudobj.url if !@vpc.nil? and !@vpc.cloudobj.nil? + vpc_id = @vpc.url if !@vpc.nil? vpc_id ||= @config['vpc']['vpc_id'] if @config['vpc'] and @config['vpc']['vpc_id'] - allrules = {} - # The set of rules might actually compose into multiple firewall - # objects, so figure that out. + if vpc_id.nil? + raise MuError, "Failed to resolve VPC for #{self}" + end + + params = { + :name => @cloud_id, + :network => vpc_id + } + @config['rules'].each { |rule| srcs = [] ruleobj = nil +# XXX 'all' and 'standard' keywords if ["tcp", "udp"].include?(rule['proto']) and (rule['port_range'] or rule['port']) ruleobj = MU::Cloud::Google.compute(:Firewall)::Allowed.new( ip_protocol: rule['proto'], @@ -83,55 +75,59 @@ def create rule['hosts'].each { |cidr| srcs << cidr } end - ["ingress", "egress"].each { |dir| - if rule[dir] or (dir == "ingress" and !rule.has_key?("egress")) - setname = @deploy.getResourceName(@mu_name+"-"+dir+"-"+(rule['deny'] ? "deny" : "allow"), max_length: 61).downcase - @cloud_id ||= setname - allrules[setname] ||= { - :name => setname, - :direction => dir.upcase, - :network => vpc_id - } - if @deploy - allrules[setname][:description] = @deploy.deploy_id - end - ['source_service_accounts', 'source_tags', 'target_tags', 'target_service_accounts'].each { |filter| - if config[filter] and config[filter].size > 0 - allrules[setname][filter.to_sym] = config[filter].dup - end - } - action = rule['deny'] ? :denied : :allowed - allrules[setname][action] ||= [] - allrules[setname][action] << ruleobj - ipparam = dir == "ingress" ? :source_ranges : :destination_ranges - allrules[setname][ipparam] ||= [] - allrules[setname][ipparam].concat(srcs) - allrules[setname][:priority] = rule['weight'] if rule['weight'] - end - } - } + dir = (rule["ingress"] or !rule["egress"]) ? "INGRESS" : "EGRESS" + if params[:direction] and params[:direction] != dir + MU.log "Google Cloud firewalls cannot mix ingress and egress rules", MU::ERR, details: @config['rules'] + raise MuError, "Google Cloud firewalls cannot mix ingress and egress rules" + end - parent_thread_id = Thread.current.object_id - threads = [] + params[:direction] = dir - allrules.each_value { |fwdesc| - threads << Thread.new { - fwobj = MU::Cloud::Google.compute(:Firewall).new(fwdesc) - MU.log "Creating firewall #{fwdesc[:name]} in project #{@project_id}", details: fwobj - resp = MU::Cloud::Google.compute(credentials: @config['credentials']).insert_firewall(@project_id, fwobj) -# XXX Check for empty (no hosts) sets -# MU.log "Can't create empty firewalls in Google Cloud, skipping #{@mu_name}", MU::WARN + if @deploy + params[:description] = @deploy.deploy_id + end + filters = if dir == "INGRESS" + ['source_service_accounts', 'source_tags'] + else + ['target_service_accounts', 'target_tags'] + end + filters.each { |filter| + if config[filter] and config[filter].size > 0 + params[filter.to_sym] = config[filter].dup + end } + action = rule['deny'] ? :denied : :allowed + params[action] ||= [] + params[action] << ruleobj + ipparam = dir == "INGRESS" ? :source_ranges : :destination_ranges + params[ipparam] ||= [] + params[ipparam].concat(srcs) + params[:priority] = rule['weight'] if rule['weight'] } - threads.each do |t| - t.join - end + fwobj = MU::Cloud::Google.compute(:Firewall).new(params) + MU.log "Creating firewall #{@cloud_id} in project #{@project_id}", details: fwobj +begin + MU::Cloud::Google.compute(credentials: @config['credentials']).insert_firewall(@project_id, fwobj) +rescue ::Google::Apis::ClientError => e + MU.log @config['project']+"/"+@config['name']+": "+@cloud_id, MU::ERR, details: @config['vpc'] + MU.log e.inspect, MU::ERR, details: fwobj + if e.message.match(/Invalid value for field/) + dependencies(use_cache: false, debug: true) + end + raise e +end + # Make sure it actually got made before we move on + desc = nil + begin + desc = MU::Cloud::Google.compute(credentials: @config['credentials']).get_firewall(@project_id, @cloud_id) + sleep 1 + end while desc.nil? + desc end # Called by {MU::Deploy#createResources} def groom - @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloudobj.cloud_id end # Log metadata about this ruleset to the currently running deployment @@ -141,7 +137,7 @@ def notify ) sg_data ||= {} sg_data["group_id"] = @cloud_id - sg_data["project_id"] = @project_id + sg_data["project_id"] = habitat_id sg_data["cloud_id"] = @cloud_id return sg_data @@ -158,24 +154,31 @@ def notify def addRule(hosts, proto: "tcp", port: nil, egress: false, port_range: "0-65535") end - # Locate an existing security group or groups and return an array containing matching AWS resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @param flags [Hash]: Optional flags - # @return [Array>]: The cloud provider's complete descriptions of matching FirewallRules - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, flags: {}, credentials: nil) - flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) + args[:project] ||= args[:habitat] + args[:project] ||= MU::Cloud::Google.defaultProject(args[:credentials]) found = {} - resp = MU::Cloud::Google.compute(credentials: credentials).list_firewalls(flags["project"]) + resp = begin + MU::Cloud::Google.compute(credentials: args[:credentials]).list_firewalls(args[:project]) + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/^(?:notFound|forbidden): /) + end if resp and resp.items resp.items.each { |fw| - next if !cloud_id.nil? and fw.name != cloud_id + next if !args[:cloud_id].nil? and fw.name != args[:cloud_id] found[fw.name] = fw } end + found end @@ -199,6 +202,8 @@ def self.quality # @return [void] def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) + return if !MU::Cloud::Google::Habitat.isLive?(flags["project"], credentials) + MU::Cloud::Google.compute(credentials: credentials).delete( "firewall", flags["project"], @@ -207,18 +212,167 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent ) end + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + def toKitten(rootparent: nil, billing: nil, habitats: nil) + + if cloud_desc.name.match(/^[a-f0-9]+$/) + gke_ish = true + cloud_desc.target_tags.each { |tag| + gke_ish = false if !tag.match(/^gke-/) + } + if gke_ish + MU.log "FirewallRule #{cloud_desc.name} appears to belong to a ContainerCluster, skipping adoption", MU::DEBUG + return nil + end + end + + bok = { + "cloud" => "Google", + "project" => @config['project'], + "credentials" => @config['credentials'] + } + + bok['rules'] = [] + bok['name'] = cloud_desc.name.dup + bok['cloud_id'] = cloud_desc.name.dup + + + cloud_desc.network.match(/(?:^|\/)projects\/(.*?)\/.*?\/networks\/([^\/]+)(?:$|\/)/) + vpc_proj = Regexp.last_match[1] + vpc_id = Regexp.last_match[2] + + if vpc_id == "default" and !@config['project'] + raise MuError, "FirewallRule toKitten: I'm in 'default' VPC but can't figure out what project I'm in" + end + + # XXX make sure this is sane (that these rules come with default VPCs) + if vpc_id == "default" and ["default-allow-icmp", "default-allow-http"].include?(cloud_desc.name) + return nil + end + + if vpc_id != "default" + bok['vpc'] = MU::Config::Ref.get( + id: vpc_id, + habitat: MU::Config::Ref.get( + id: vpc_proj, + cloud: "Google", + credentials: @credentials, + type: "habitats" + ), + cloud: "Google", + credentials: @config['credentials'], + type: "vpcs" + ) + end + + byport = {} + + rule_list = [] + is_deny = false + if cloud_desc.denied + rule_list = cloud_desc.denied + is_deny = true + else + rule_list = cloud_desc.allowed + end + + rule_list.each { |rule| + hosts = if cloud_desc.direction == "INGRESS" + cloud_desc.source_ranges ? cloud_desc.source_ranges : ["0.0.0.0/0"] + else + cloud_desc.destination_ranges ? cloud_desc.destination_ranges : ["0.0.0.0/0"] + end + hosts.map! { |h| + h = h+"/32" if h.match(/^\d+\.\d+\.\d+\.\d+$/) + h + } + proto = rule.ip_protocol ? rule.ip_protocol : "all" + + if rule.ports + rule.ports.each { |ports| + ports = "0-65535" if ["1-65535", "1-65536", "0-65536"].include?(ports) + byport[ports] ||= {} + byport[ports][hosts] ||= [] + byport[ports][hosts] << proto + } + else + byport["0-65535"] ||= {} + byport["0-65535"][hosts] ||= [] + byport["0-65535"][hosts] << proto + end + + } + + byport.each_pair { |ports, hostlists| + hostlists.each_pair { |hostlist, protos| + protolist = if protos.sort.uniq == PROTOS.sort.uniq + ["all"] + elsif protos.sort.uniq == ["icmp", "tcp", "udp"] + ["standard"] + else + protos + end + protolist.each { |proto| + rule = { + "proto" => proto, + "hosts" => hostlist + } + rule["deny"] = true if is_deny + if cloud_desc.priority and cloud_desc.priority != 1000 + rule["weight"] = cloud_desc.priority + end + if ports.match(/-/) + rule["port_range"] = ports + else + rule["port"] = ports.to_i + end + if cloud_desc.source_service_accounts + rule["source_service_accounts"] = cloud_desc.source_service_accounts + end + if cloud_desc.source_tags + rule["source_tags"] = cloud_desc.source_tags + end + if cloud_desc.target_service_accounts + rule["target_service_accounts"] = cloud_desc.target_service_accounts + end + if cloud_desc.target_tags + rule["target_tags"] = cloud_desc.target_tags + end + if cloud_desc.direction == "EGRESS" + rule['egress'] = true + rule['ingress'] = false + end + bok['rules'] << rule + } + } + } + + bok + end + # Cloud-specific configuration properties. # @param config [MU::Config]: The calling MU::Config object # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource - def self.schema(config) + def self.schema(config = nil) toplevel_required = [] -# ['source_ranges', 'source_service_accounts', 'source_tags', 'target_ranges', 'target_service_accounts'].each { |filter| schema = { "rules" => { "items" => { "properties" => { + "weight" => { + "type" => "integer", + "description" => "Explicitly set a priority for this firewall rule, between 0 and 65535, with lower numbered priority rules having greater precedence." + }, + "deny" => { + "type" => "boolean", + "default" => false, + "description" => "Set this rule to +DENY+ traffic instead of +ALLOW+" + }, "proto" => { - "enum" => ["udp", "tcp", "icmp", "all"] + "description" => "The protocol to allow with this rule. The +standard+ keyword will expand to a series of identical rules covering +icmp+, +tcp+, and +udp; the +all+ keyword will expand to a series of identical rules for all supported protocols.", + "enum" => PROTOS + ["all", "standard"] }, "source_tags" => { "type" => "array", @@ -265,6 +419,113 @@ def self.schema(config) # @return [Boolean]: True if validation succeeded, False otherwise def self.validateConfig(acl, config) ok = true + acl['project'] ||= MU::Cloud::Google.defaultProject(acl['credentials']) + + if acl['vpc'] + acl['vpc']['project'] ||= acl['project'] + acl['vpc'] = MU::Cloud::Google::VPC.pickVPC( + acl['vpc'], + acl, + "firewall_rule", + config + ) + end + + acl['rules'] ||= [] + + # Firewall entries without rules are illegal in GCP, so insert a + # default-deny placeholder. + if acl['rules'].empty? + acl['rules'] << { + "deny" => true, + "proto" => "all", + "hosts" => ["0.0.0.0/0"], + "weight" => 65535 + } + end + + # First, expand some of our protocol shorthand into a real list + append = [] + delete = [] + acl['rules'].each { |r| + if !r['egress'] + if !r['source_tags'] and !r['source_service_accounts'] and + (!r['hosts'] or r['hosts'].empty?) + r['hosts'] = ['0.0.0.0/0'] + end + else + if !r['destination_tags'] and !r['destination_service_accounts'] and + (!r['hosts'] or r['hosts'].empty?) + r['hosts'] = ['0.0.0.0/0'] + end + end + + if r['proto'] == "standard" + STD_PROTOS.each { |p| + newrule = r.dup + newrule['proto'] = p + append << newrule + } + delete << r + elsif r['proto'] == "all" + PROTOS.each { |p| + newrule = r.dup + newrule['proto'] = p + append << newrule + } + delete << r + end + + } + delete.each { |r| + acl['rules'].delete(r) + } + acl['rules'].concat(append) + + # Next, bucket these by what combination of allow/deny and + # ingress/egress rule they are. If we have more than one + # classification + rules_by_class = { + "allow-ingress" => [], + "allow-egress" => [], + "deny-ingress" => [], + "deny-egress" => [], + } + + acl['rules'].each { |rule| + if rule['deny'] + if rule['egress'] + rules_by_class["deny-egress"] << rule + else + rules_by_class["deny-ingress"] << rule + end + else + if rule['egress'] + rules_by_class["allow-egress"] << rule + else + rules_by_class["allow-ingress"] << rule + end + end + } + + rules_by_class.reject! { |k, v| v.size == 0 } + + # Generate other firewall rule objects to cover the other behaviors + # we've requested, if indeed we've done so. + if rules_by_class.size > 1 + keep = rules_by_class.keys.first + acl['rules'] = rules_by_class[keep] + rules_by_class.delete(keep) + rules_by_class.each_pair { |behaviors, rules| + newrule = acl.dup + newrule['name'] += "-"+behaviors + newrule['rules'] = rules + ok = false if !config.insertKitten(newrule, "firewall_rules") + + } + end + + ok end private diff --git a/modules/mu/clouds/google/folder.rb b/modules/mu/clouds/google/folder.rb index 8298c23b2..cf2ff9ea4 100644 --- a/modules/mu/clouds/google/folder.rb +++ b/modules/mu/clouds/google/folder.rb @@ -15,30 +15,16 @@ module MU class Cloud class Google - # Creates an Google project as configured in {MU::Config::BasketofKittens::folders} + # Creates a Google folder as configured in {MU::Config::BasketofKittens::folders} class Folder < MU::Cloud::Folder - @deploy = nil - @config = nil - @parent = nil - - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::folders} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - - if !mu_name.nil? - @mu_name = mu_name - elsif @config['scrub_mu_isms'] - @mu_name = @config['name'] - else - @mu_name = @deploy.getResourceName(@config['name']) - end + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + cloud_desc if @cloud_id # XXX this maybe isn't my job + + @mu_name ||= @deploy.getResourceName(@config['name']) end # Called automatically by {MU::Deploy#createResources} @@ -54,6 +40,9 @@ def create display_name: name_string } + if @config['parent']['name'] and !@config['parent']['id'] + @config['parent']['deploy_id'] = @deploy.deploy_id + end parent = MU::Cloud::Google::Folder.resolveParent(@config['parent'], credentials: @config['credentials']) folder_obj = MU::Cloud::Google.folder(:Folder).new(params) @@ -79,6 +68,20 @@ def create end end while found.size == 0 + @habitat = parent + + end + + # Retrieve the IAM bindings for this folder (associates between IAM roles and groups/users) + def bindings + MU::Cloud::Google::Folder.bindings(@cloud_id, credentials: @config['credentials']) + end + + # Retrieve the IAM bindings for this folder (associates between IAM roles and groups/users) + # @param folder [String]: + # @param credentials [String]: + def self.bindings(folder, credentials: nil) + MU::Cloud::Google.folder(credentials: credentials).get_folder_iam_policy(folder).bindings end # Given a {MU::Config::Folder.reference} configuration block, resolve @@ -88,9 +91,9 @@ def create # @return [String] def self.resolveParent(parentblock, credentials: nil) my_org = MU::Cloud::Google.getOrg(credentials) - if !parentblock or parentblock['id'] == my_org.name or + if my_org and (!parentblock or parentblock['id'] == my_org.name or parentblock['name'] == my_org.display_name or (parentblock['id'] and - "organizations/"+parentblock['id'] == my_org.name) + "organizations/"+parentblock['id'] == my_org.name)) return my_org.name end @@ -103,12 +106,12 @@ def self.resolveParent(parentblock, credentials: nil) name: parentblock['name'] ).first if sib_folder - return "folders/"+sib_folder.cloudobj.cloud_id + return sib_folder.cloud_desc.name end end begin - found = MU::Cloud::Google::Folder.find(cloud_id: parentblock['id'], credentials: credentials, flags: { 'display_name' => parentblock['name'] }) + found = MU::Cloud::Google::Folder.find(cloud_id: parentblock['id'], credentials: credentials, flags: { 'display_name' => parentblock['name'] }) rescue ::Google::Apis::ClientError => e if !e.message.match(/Invalid request status_code: 404/) raise e @@ -123,11 +126,14 @@ def self.resolveParent(parentblock, credentials: nil) end # Return the cloud descriptor for the Folder + # @return [Google::Apis::Core::Hashable] def cloud_desc - MU::Cloud::Google::Folder.find(cloud_id: @cloud_id).values.first + @cached_cloud_desc ||= MU::Cloud::Google::Folder.find(cloud_id: @cloud_id, credentials: @config['credentials']).values.first + @habitat_id ||= @cached_cloud_desc.parent.sub(/^(folders|organizations)\//, "") + @cached_cloud_desc end - # Return the metadata for this project's configuration + # Return the metadata for this folders's configuration # @return [Hash] def notify desc = MU.structToHash(MU::Cloud::Google.folder(credentials: @config['credentials']).get_folder("folders/"+@cloud_id)) @@ -147,7 +153,7 @@ def self.isGlobal? # Denote whether this resource implementation is experiment, ready for # testing, or ready for production use. def self.quality - MU::Cloud::BETA + MU::Cloud::RELEASE end # Remove all Google projects associated with the currently loaded deployment. Try to, anyway. @@ -158,54 +164,70 @@ def self.cleanup(noop: false, ignoremaster: false, credentials: nil, flags: {}, # We can't label GCP folders, and their names are too short to encode # Mu deploy IDs, so all we can do is rely on flags['known'] passed in # from cleanup, which relies on our metadata to know what's ours. - +#noop = true if flags and flags['known'] + threads = [] flags['known'].each { |cloud_id| - found = self.find(cloud_id: cloud_id, credentials: credentials) - if found.size > 0 and found.values.first.lifecycle_state == "ACTIVE" - MU.log "Deleting folder #{found.values.first.display_name} (#{found.keys.first})" - if !noop - max_retries = 10 - retries = 0 - success = false - begin - MU::Cloud::Google.folder(credentials: credentials).delete_folder( - "folders/"+found.keys.first - ) - found = self.find(cloud_id: cloud_id, credentials: credentials) - if found and found.size > 0 and found.values.first.lifecycle_state != "DELETE_REQUESTED" - if retries < max_retries + threads << Thread.new { + + found = self.find(cloud_id: cloud_id, credentials: credentials) + + if found.size > 0 and found.values.first.lifecycle_state == "ACTIVE" + MU.log "Deleting folder #{found.values.first.display_name} (#{found.keys.first})" + if !noop + max_retries = 10 + retries = 0 + success = false + begin + MU::Cloud::Google.folder(credentials: credentials).delete_folder( + "folders/"+found.keys.first + ) + found = self.find(cloud_id: cloud_id, credentials: credentials) + if found and found.size > 0 and found.values.first.lifecycle_state != "DELETE_REQUESTED" + if retries < max_retries + sleep 30 + retries += 1 + puts retries + else + MU.log "Folder #{cloud_id} still exists after #{max_retries.to_s} attempts to delete", MU::ERR + break + end + else + success = true + end + + rescue ::Google::Apis::ClientError => e +# XXX maybe see if the folder has disappeared already? +# XXX look for child folders that haven't been deleted, that's what this tends +# to mean + if e.message.match(/failedPrecondition/) and retries < max_retries sleep 30 retries += 1 - puts retries + retry else - MU.log "Folder #{cloud_id} still exists after #{max_retries.to_s} attempts to delete", MU::ERR + MU.log "Got 'failedPrecondition' a bunch while trying to delete #{found.values.first.display_name} (#{found.keys.first})", MU::ERR break end - else - success = true - end - - rescue ::Google::Apis::ClientError => e - if e.message.match(/failedPrecondition/) and retries < max_retries - sleep 30 - retries += 1 - retry - else - raise e - end - end while !success + end while !success + end end - end + } + } + threads.each { |t| + t.join } end end - # Locate an existing project - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching project - def self.find(cloud_id: nil, credentials: nil, flags: {}, tag_key: nil, tag_value: nil) + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) found = {} # Recursively search a GCP folder hierarchy for a folder matching our @@ -227,33 +249,88 @@ def self.find_matching_folder(parent, name: nil, id: nil, credentials: nil) nil end - if cloud_id - found[cloud_id.sub(/^folders\//, "")] = MU::Cloud::Google.folder(credentials: credentials).get_folder("folders/"+cloud_id.sub(/^folders\//, "")) - elsif flags['display_name'] - parent = if flags['parent_id'] - flags['parent_id'] - else - my_org = MU::Cloud::Google.getOrg(credentials) - my_org.name - end + parent = if args[:flags] and args[:flags]['parent_id'] + args[:flags]['parent_id'] + else + my_org = MU::Cloud::Google.getOrg(args[:credentials]) + my_org.name + end + + if args[:cloud_id] + raw_id = args[:cloud_id].sub(/^folders\//, "") + resp = MU::Cloud::Google.folder(credentials: args[:credentials]).get_folder("folders/"+raw_id) + found[resp.name] = resp if resp + + elsif args[:flags] and args[:flags]['display_name'] if parent - resp = self.find_matching_folder(parent, name: flags['display_name'], credentials: credentials) + resp = self.find_matching_folder(parent, name: args[:flags]['display_name'], credentials: args[:credentials]) if resp - found[resp.name.sub(/^folders\//, "")] = resp + found[resp.name] = resp end end + else + resp = MU::Cloud::Google.folder(credentials: args[:credentials]).list_folders(parent: parent) + if resp and resp.folders + resp.folders.each { |folder| + next if folder.lifecycle_state == "DELETE_REQUESTED" + found[folder.name] = folder + # recurse so that we'll pick up child folders + children = self.find( + credentials: args[:credentials], + flags: { 'parent_id' => folder.name } + ) + if !children.nil? and !children.empty? + found.merge!(children) + end + } + end end - + found end + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + def toKitten(rootparent: nil, billing: nil, habitats: nil) + bok = { + "cloud" => "Google", + "credentials" => @config['credentials'] + } + + bok['display_name'] = cloud_desc.display_name + bok['cloud_id'] = cloud_desc.name + bok['name'] = cloud_desc.display_name#+bok['cloud_id'] # only way to guarantee uniqueness + if cloud_desc.parent.match(/^folders\/(.*)/) +MU.log bok['display_name']+" generating reference", MU::NOTICE, details: cloud_desc.parent + bok['parent'] = MU::Config::Ref.get( + id: cloud_desc.parent, + cloud: "Google", + credentials: @config['credentials'], + type: "folders" + ) + elsif rootparent + bok['parent'] = { + 'id' => rootparent.is_a?(String) ? rootparent : rootparent.cloud_desc.name + } + else + bok['parent'] = { 'id' => cloud_desc.parent } + end + + bok + end + # Cloud-specific configuration properties. # @param config [MU::Config]: The calling MU::Config object # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource def self.schema(config) toplevel_required = [] schema = { + "display_name" => { + "type" => "string", + "description" => "The +display_name+ field of this folder, specified only if we want it to be something other than the automatically-generated string derived from the +name+ field.", + } } [toplevel_required, schema] end @@ -266,7 +343,7 @@ def self.validateConfig(folder, configurator) ok = true if !MU::Cloud::Google.getOrg(folder['credentials']) - MU.log "Cannot manage Google Cloud projects in environments without an organization. See also: https://cloud.google.com/resource-manager/docs/creating-managing-organization", MU::ERR + MU.log "Cannot manage Google Cloud folders in environments without an organization", MU::ERR ok = false end diff --git a/modules/mu/clouds/google/group.rb b/modules/mu/clouds/google/group.rb index 51663ccf8..e0d4e14d5 100644 --- a/modules/mu/clouds/google/group.rb +++ b/modules/mu/clouds/google/group.rb @@ -17,36 +17,102 @@ class Cloud class Google # A group as configured in {MU::Config::BasketofKittens::groups} class Group < MU::Cloud::Group - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::groups} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + @mu_name ||= @deploy.getResourceName(@config["name"]) end # Called automatically by {MU::Deploy#createResources} def create - bind_group + if !@config['external'] + if !@config['email'] + domains = MU::Cloud::Google.admin_directory(credentials: @credentials).list_domains(@customer) + @config['email'] = @mu_name.downcase+"@"+domains.domains.first.domain_name + end + group_obj = MU::Cloud::Google.admin_directory(:Group).new( + name: @mu_name, + email: @config['email'], + description: @deploy.deploy_id + ) + + MU.log "Creating group #{@mu_name}", details: group_obj + + resp = MU::Cloud::Google.admin_directory(credentials: @credentials).insert_group(group_obj) + @cloud_id = resp.email + + MU::Cloud::Google::Role.bindFromConfig("group", @cloud_id, @config['roles'], credentials: @config['credentials']) + else + @cloud_id = @config['name'].sub(/@.*/, "")+"@"+@config['domain'] + end end # Called automatically by {MU::Deploy#createResources} def groom - bind_group + MU::Cloud::Google::Role.bindFromConfig("group", @cloud_id, @config['roles'], credentials: @config['credentials'], debug: true) + + if @config['members'] + resolved_desired = [] + @config['members'].each { |m| + sibling_user = @deploy.findLitterMate(name: m, type: "users") + usermail = if sibling_user + sibling_user.cloud_id + elsif !m.match(/@/) + domains = MU::Cloud::Google.admin_directory(credentials: @credentials).list_domains(@customer) + m+"@"+domains.domains.first.domain_name + else + m + end + resolved_desired << usermail + next if members.include?(usermail) + MU.log "Adding user #{usermail} to group #{@mu_name}" + MU::Cloud::Google.admin_directory(credentials: @credentials).insert_member( + @cloud_id, + MU::Cloud::Google.admin_directory(:Member).new( + email: usermail + ) + ) + } + + deletia = members - resolved_desired + deletia.each { |m| + MU.log "Removing user #{m} from group #{@mu_name}", MU::NOTICE + MU::Cloud::Google.admin_directory(credentials: @credentials).delete_member(@cloud_id, m) + } + + # Theoretically there can be a delay + begin + if members.sort != resolved_desired.sort + sleep 3 + end + end while members.sort != resolved_desired.sort + end + + end + + # Retrieve a list of users (by cloud id) of this group + def members + resp = MU::Cloud::Google.admin_directory(credentials: @credentials).list_members(@cloud_id) + members = [] + if resp and resp.members + members = resp.members.map { |m| m.email } +# XXX reject status != "ACTIVE" ? + end + members end # Return the metadata for this group configuration # @return [Hash] def notify - { - } + if !@config['external'] + base = MU.structToHash(cloud_desc) + end + base ||= {} + + base end # Does this resource type exist as a global (cloud-wide) artifact, or @@ -56,10 +122,18 @@ def self.isGlobal? true end + # Return the list of "container" resource types in which this resource + # can reside. The list will include an explicit nil if this resource + # can exist outside of any container. + # @return [Array] + def self.canLiveIn + [nil] + end + # Denote whether this resource implementation is experiment, ready for # testing, or ready for production use. def self.quality - MU::Cloud::ALPHA + MU::Cloud::BETA end # Remove all groups associated with the currently loaded deployment. @@ -68,19 +142,90 @@ def self.quality # @param region [String]: The cloud provider region # @return [void] def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) + my_domains = MU::Cloud::Google.getDomains(credentials) + my_org = MU::Cloud::Google.getOrg(credentials) + + if my_org + groups = MU::Cloud::Google.admin_directory(credentials: credentials).list_groups(customer: MU::Cloud::Google.customerID(credentials)).groups + if groups + groups.each { |group| + if group.description == MU.deploy_id + MU.log "Deleting group #{group.name} from #{my_org.display_name}", details: group + if !noop + MU::Cloud::Google.admin_directory(credentials: credentials).delete_group(group.id) + end + end + } + end + end + + if flags['known'] + flags['known'].each { |group| + MU::Cloud::Google::Role.removeBindings("group", group, credentials: credentials, noop: noop) + } + end end - # Locate an existing group group. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching group group. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}, tag_key: nil, tag_value: nil) - flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) - found = nil + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) + found = {} + + # The API treats the email address field as its main identifier, so + # we'll go ahead and respect that. + if args[:cloud_id] + begin + resp = MU::Cloud::Google.admin_directory(credentials: args[:credentials]).get_group(args[:cloud_id]) + found[resp.email] = resp if resp + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/forbidden: /) + end + else + resp = MU::Cloud::Google.admin_directory(credentials: args[:credentials]).list_groups(customer: MU::Cloud::Google.customerID(args[:credentials])) + if resp and resp.groups + found = Hash[resp.groups.map { |g| [g.email, g] }] + end + end +# XXX what about Google Groups groups and other external groups? Where do we fish for those? Do we even need to? found end + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + def toKitten(rootparent: nil, billing: nil, habitats: nil) + + bok = { + "cloud" => "Google", + "credentials" => @config['credentials'] + } + + bok['name'] = cloud_desc.name + bok['cloud_id'] = cloud_desc.email + bok['members'] = members + bok['members'].each { |m| + m = MU::Config::Ref.get( + id: m, + cloud: "Google", + credentials: @config['credentials'], + type: "users" + ) + } + group_roles = MU::Cloud::Google::Role.getAllBindings(@config['credentials'])["by_entity"] + if group_roles["group"] and group_roles["group"][bok['cloud_id']] and + group_roles["group"][bok['cloud_id']].size > 0 + bok['roles'] = MU::Cloud::Google::Role.entityBindingsToSchema(group_roles["group"][bok['cloud_id']], credentials: @config['credentials']) + end + + bok + end + # Cloud-specific configuration properties. # @param config [MU::Config]: The calling MU::Config object # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource @@ -89,20 +234,32 @@ def self.schema(config) schema = { "name" => { "type" => "string", - "description" => "This must be the email address of an existing Google Group (+foo@googlegroups.com+), or of a federated GSuite or Cloud Identity domain group from your organization." + "description" => "This can include an optional @domain component (foo@example.com). + +If the domain portion is not specified, and we manage exactly one GSuite or Cloud Identity domain, we will attempt to create the group in that domain. + +If we do not manage any domains, and none are specified, we will assume @googlegroups.com for the domain and attempt to bind an existing external Google Group to roles under our jurisdiction. + +If the domain portion is specified, and our credentials can manage that domain via GSuite or Cloud Identity, we will attempt to create the group in that domain. + +If it is a domain we do not manage, we will attempt to bind an existing external group from that domain to roles under our jurisdiction. + +If we are binding (rather than creating) a group and no roles are specified, we will default to +roles/viewer+ at the organization scope. If our credentials do not manage an organization, we will grant this role in our default project. + +" + }, + "domain" => { + "type" => "string", + "description" => "The domain from which the group originates or in which it should be created. This can instead be embedded in the {name} field: +foo@example.com+." }, + "external" => { + "type" => "boolean", + "description" => "Explicitly flag this group as originating from an external domain. This should always autodetect correctly." + }, + "roles" => { "type" => "array", - "description" => "One or more Google IAM roles to associate with this group.", - "default" => ["roles/viewer"], - "items" => { - "type" => "string", - "description" => "One or more Google IAM roles to associate with this group. Google Cloud groups are not created directly; pre-existing Google Groups are associated with a project by being bound to one or more roles in that project. If no roles are specified, we default to +roles/viewer+, which permits read-only access project-wide." - } - }, - "project" => { - "type" => "string", - "description" => "The project into which to deploy resources" + "items" => MU::Cloud::Google::Role.ref_schema } } [toplevel_required, schema] @@ -114,64 +271,97 @@ def self.schema(config) # @return [Boolean]: True if validation succeeded, False otherwise def self.validateConfig(group, configurator) ok = true - if group['members'] and group['members'].size > 0 and - !$MU_CFG['google']['masquerade_as'] - MU.log "Cannot change Google group memberships in non-GSuite environments.\nVisit https://groups.google.com to manage groups.", MU::ERR - ok = false - end - ok - end + my_domains = MU::Cloud::Google.getDomains(group['credentials']) + my_org = MU::Cloud::Google.getOrg(group['credentials']) - private + if group['name'].match(/@(.*+)$/) + domain = Regexp.last_match[1].downcase + if domain and group['domain'] and domain != group['domain'].downcase + MU.log "Group #{group['name']} had a domain component, but the domain field was also specified (#{group['domain']}) and they don't match." + ok = false + end + group['domain'] = domain + + if !my_domains or !my_domains.include?(domain) + group['external'] = true - def bind_group - bindings = [] - ext_policy = MU::Cloud::Google.resource_manager(credentials: @config['credentials']).get_project_iam_policy( - @config['project'] - ) - - change_needed = false - @config['roles'].each { |role| - seen = false - ext_policy.bindings.each { |b| - if b.role == role - seen = true - if !b.members.include?("user:"+@config['name']) - change_needed = true - b.members << "group:"+@config['name'] + if !["googlegroups.com", "google.com"].include?(domain) + MU.log "#{group['name']} appears to be a member of a domain that our credentials (#{group['credentials']}) do not manage; attempts to grant access for this group may fail!", MU::WARN + end + + if !group['roles'] or group['roles'].empty? + group['roles'] = [ + { + "role" => { + "id" => "roles/viewer" + } + } + ] + if my_org + group['roles'][0]["organizations"] = [my_org.name] + else + group['roles'][0]["projects"] = { + "id" => group["project"] + } end + MU.log "External Google group specified with no role binding, will grant 'viewer' in #{my_org ? "organization #{my_org.display_name}" : "project #{group['project']}"}", MU::WARN end - } - if !seen - ext_policy.bindings << MU::Cloud::Google.resource_manager(:Binding).new( - role: role, - members: ["group:"+@config['name']] - ) - change_needed = true end - } + else + if !group['domain'] + if my_domains.size == 1 + group['domain'] = my_domains.first + elsif my_domains.size > 1 + MU.log "Google interactive User #{group['name']} did not specify a domain, and we have multiple defaults available. Must specify exactly one.", MU::ERR, details: my_domains + ok = false + else + group['domain'] = "googlegroups.com" + end + end + end - if change_needed - req_obj = MU::Cloud::Google.resource_manager(:SetIamPolicyRequest).new( - policy: ext_policy - ) - MU.log "Adding group #{@config['name']} to Google Cloud project #{@config['project']}", details: @config['roles'] - begin - MU::Cloud::Google.resource_manager(credentials: @config['credentials']).set_project_iam_policy( - @config['project'], - req_obj - ) - rescue ::Google::Apis::ClientError => e - if e.message.match(/does not exist/i) and !MU::Cloud::Google.credConfig(@config['credentials'])['masquerade_as'] - raise MuError, "Group #{@config['name']} does not exist, and we cannot create Google groups in non-GSuite environments.\nVisit https://groups.google.com to manage groups." - end - raise e + credcfg = MU::Cloud::Google.credConfig(group['credentials']) + + if group['external'] and group['members'] + MU.log "Cannot manage memberships for external group #{group['name']}", MU::ERR + if group['domain'] == "googlegroups.com" + MU.log "Visit https://groups.google.com to manage Google Groups.", MU::ERR end + ok = false end + + if group['members'] + group['members'].each { |m| + if configurator.haveLitterMate?(m, "users") + group['dependencies'] ||= [] + group['dependencies'] << { + "name" => m, + "type" => "user" + } + end + } + end + + if group['roles'] + group['roles'].each { |r| + if r['role'] and r['role']['name'] and + (!r['role']['deploy_id'] and !r['role']['id']) + group['dependencies'] ||= [] + group['dependencies'] << { + "type" => "role", + "name" => r['role']['name'] + } + end + } + end + + ok end + private + end end end diff --git a/modules/mu/clouds/google/habitat.rb b/modules/mu/clouds/google/habitat.rb index 7bd249a8e..c9322210d 100644 --- a/modules/mu/clouds/google/habitat.rb +++ b/modules/mu/clouds/google/habitat.rb @@ -17,27 +17,19 @@ class Cloud class Google # Creates an Google project as configured in {MU::Config::BasketofKittens::habitats} class Habitat < MU::Cloud::Habitat - @deploy = nil - @config = nil - - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::habitats} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - - if !mu_name.nil? - @mu_name = mu_name - elsif @config['scrub_mu_isms'] - @mu_name = @config['name'] - else - @mu_name = @deploy.getResourceName(@config['name']) + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + cloud_desc if @cloud_id # XXX maybe this isn't my job + + # XXX this definitely isn't my job + if !@cloud_id and cloud_desc and cloud_desc.project_id + @cloud_id = cloud_desc.project_id end + + @mu_name ||= @deploy.getResourceName(@config['name']) end # Called automatically by {MU::Deploy#createResources} @@ -47,12 +39,13 @@ def create name_string = if @config['scrub_mu_isms'] @config["name"] else - @deploy.getResourceName(@config["name"], max_length: 30).downcase + @deploy.getResourceName(@config["name"], max_length: 30) end + display_name = @config['display_name'] || name_string.gsub(/[^a-z0-9\-'"\s!]/i, "-") params = { - name: name_string, - project_id: name_string, + name: display_name, + project_id: name_string.downcase.gsub(/[^0-9a-z\-]/, "-") } MU::MommaCat.listStandardTags.each_pair { |name, value| @@ -65,6 +58,9 @@ def create params[:labels] = labels end + if @config['parent']['name'] and !@config['parent']['id'] + @config['parent']['deploy_id'] = @deploy.deploy_id + end parent = MU::Cloud::Google::Folder.resolveParent(@config['parent'], credentials: @config['credentials']) if !parent MU.log "Unable to resolve parent resource of Google Project #{@config['name']}", MU::ERR, details: @config['parent'] @@ -79,22 +75,31 @@ def create project_obj = MU::Cloud::Google.resource_manager(:Project).new(params) - MU.log "Creating project #{name_string} under #{parent}", details: project_obj - MU::Cloud::Google.resource_manager(credentials: @config['credentials']).create_project(project_obj) + MU.log "Creating project #{params[:project_id]} (#{params[:name]}) under #{parent} (#{@config['credentials']})", details: project_obj + + begin + pp MU::Cloud::Google.resource_manager(credentials: @config['credentials']).create_project(project_obj) + rescue ::Google::Apis::ClientError => e + MU.log "Got #{e.message} attempting to create #{params[:project_id]}", MU::ERR, details: project_obj + end found = false retries = 0 begin - resp = MU::Cloud::Google.resource_manager(credentials: credentials).list_projects +# can... can we filter this? + resp = MU::Cloud::Google.resource_manager(credentials: credentials).list_projects(filter: "id:#{name_string.downcase.gsub(/[^0-9a-z\-]/, "-")}") if resp and resp.projects resp.projects.each { |p| - if p.name == name_string.downcase + if p.project_id == name_string.downcase.gsub(/[^0-9a-z\-]/, "-") found = true end } end if !found + if retries > 30 + raise MuError, "Project #{name_string} never showed up in list_projects after I created it!" + end if retries > 0 and (retries % 3) == 0 MU.log "Waiting for Google Cloud project #{name_string} to appear in list_projects results...", MU::NOTICE end @@ -104,8 +109,16 @@ def create end while !found - @cloud_id = name_string.downcase - setProjectBilling + @cloud_id = params[:project_id] + @habitat_id = parent_id + begin + setProjectBilling + rescue Exception => e + MU.log "Failed to set billing account #{@config['billing_acct']} on project #{@cloud_id}: #{e.message}", MU::ERR + MU::Cloud::Google.resource_manager(credentials: @config['credentials']).delete_project(@cloud_id) + raise e + end + MU.log "Project #{params[:project_id]} (#{params[:name]}) created" end # Called automatically by {MU::Deploy#createResources} @@ -113,6 +126,18 @@ def groom setProjectBilling end + # Retrieve the IAM bindings for this project (associates between IAM roles and groups/users) + def bindings + MU::Cloud::Google::Habitat.bindings(@cloud_id, credentials: @config['credentials']) + end + + # Retrieve the IAM bindings for this project (associates between IAM roles and groups/users) + # @param project [String]: + # @param credentials [String]: + def self.bindings(project, credentials: nil) + MU::Cloud::Google.resource_manager(credentials: credentials).get_project_iam_policy(project).bindings + end + # Associate a billing account with this project. If none is specified in # our configuration, use the billing account tied the the default # project of our credential set. @@ -130,17 +155,26 @@ def setProjectBilling project_id: @cloud_id ) MU.log "Associating project #{@cloud_id} with billing account #{@config['billing_acct']}" - MU::Cloud::Google.billing(credentials: credentials).update_project_billing_info( - "projects/"+@cloud_id, - billing_obj - ) + begin + MU::Cloud::Google.billing(credentials: credentials).update_project_billing_info( + "projects/"+@cloud_id, + billing_obj + ) + rescue ::Google::Apis::ClientError => e + MU.log "Error setting billing for #{@cloud_id}: "+e.message, MU::ERR, details: billing_obj + end end end # Return the cloud descriptor for the Habitat + # @return [Google::Apis::Core::Hashable] def cloud_desc - MU::Cloud::Google::Habitat.find(cloud_id: @cloud_id).values.first + @cached_cloud_desc ||= MU::Cloud::Google::Habitat.find(cloud_id: @cloud_id).values.first + if @cached_cloud_desc and @cached_cloud_desc.parent + @habitat_id ||= @cached_cloud_desc.parent.id + end + @cached_cloud_desc end # Return the metadata for this project's configuration @@ -159,7 +193,27 @@ def self.isGlobal? # Denote whether this resource implementation is experiment, ready for # testing, or ready for production use. def self.quality - MU::Cloud::BETA + MU::Cloud::RELEASE + end + + # Check whether is in the +ACTIVE+ state and has billing enabled. + # @param project_id [String] + # @return [Boolean] + def self.isLive?(project_id, credentials = nil) + project = MU::Cloud::Google::Habitat.find(cloud_id: project_id, credentials: credentials).values.first + return false if project.nil? or project.lifecycle_state != "ACTIVE" + + begin + billing = MU::Cloud::Google.billing(credentials: credentials).get_project_billing_info("projects/"+project_id) + if !billing or !billing.billing_account_name or + billing.billing_account_name.empty? + return false + end + rescue ::Google::Apis::ClientError => e + return false + end + + true end # Remove all Google projects associated with the currently loaded deployment. Try to, anyway. @@ -173,15 +227,16 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent resp.projects.each { |p| if p.labels and p.labels["mu-id"] == MU.deploy_id.downcase and p.lifecycle_state == "ACTIVE" - MU.log "Deleting project #{p.name}", details: p + MU.log "Deleting project #{p.project_id} (#{p.name})", details: p if !noop begin - MU::Cloud::Google.resource_manager(credentials: credentials).delete_project(p.name) + MU::Cloud::Google.resource_manager(credentials: credentials).delete_project(p.project_id) rescue ::Google::Apis::ClientError => e if e.message.match(/Cannot delete an inactive project/) # this is fine else - raise e + MU.log "Got #{e.message} trying to delete project #{p.project_id} (#{p.name})", MU::ERR + next end end end @@ -190,28 +245,94 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent end end + @@list_projects_cache = nil + # Locate an existing project - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching project - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}, tag_key: nil, tag_value: nil) + # @return [Hash]: The cloud provider's complete descriptions of matching project + def self.find(**args) + args[:project] ||= args[:habitat] + args[:cloud_id] ||= args[:project] +#MU.log "habitat.find called by #{caller[0]}", MU::WARN, details: args found = {} - if cloud_id - resp = MU::Cloud::Google.resource_manager(credentials: credentials).list_projects( - filter: "name:#{cloud_id}" + +# XXX we probably want to cache this +# XXX but why are we being called over and over? + + if args[:cloud_id] + resp = MU::Cloud::Google.resource_manager(credentials: args[:credentials]).list_projects( + filter: "id:#{args[:cloud_id]}" ) - found[resp.name] = resp.projects.first if resp and resp.projects + + if resp and resp.projects and resp.projects.size == 1 + found[args[:cloud_id]] = resp.projects.first if resp and resp.projects + else + # it's loony that there's no filter for project_number + resp = MU::Cloud::Google.resource_manager(credentials: args[:credentials]).list_projects + resp.projects.each { |p| + if p.project_number.to_s == args[:cloud_id].to_s + found[args[:cloud_id]] = p + break + end + } + end else - resp = MU::Cloud::Google.resource_manager(credentials: credentials).list_projects().projects - resp.each { |p| - found[p.name] = p + return @@list_projects_cache if @@list_projects_cache # XXX decide on stale-ness after time or something + resp = MU::Cloud::Google.resource_manager(credentials: args[:credentials]).list_projects#(page_token: page_token) + resp.projects.each { |p| + next if p.lifecycle_state == "DELETE_REQUESTED" + found[p.project_id] = p } + @@list_projects_cache = found end - + found end + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + def toKitten(rootparent: nil, billing: nil, habitats: nil) + bok = { + "cloud" => "Google", + "credentials" => @config['credentials'] + } + + bok['name'] = cloud_desc.project_id + bok['cloud_id'] = cloud_desc.project_id +# if cloud_desc.name != cloud_desc.project_id + bok['display_name'] = cloud_desc.name +# end + + if cloud_desc.parent and cloud_desc.parent.id + if cloud_desc.parent.type == "folder" + bok['parent'] = MU::Config::Ref.get( + id: "folders/"+cloud_desc.parent.id, # honestly, Google, make up your mind about your identifiers + cloud: "Google", + credentials: @config['credentials'], + type: "folders" + ) + elsif rootparent + bok['parent'] = { + 'id' => rootparent.is_a?(String) ? rootparent : rootparent.cloud_desc.name + } + else + # org parent is *probably* safe to infer from credentials + end + end + + if billing + bok['billing_acct'] = billing + else + cur_billing = MU::Cloud::Google.billing(credentials: @config['credentials']).get_project_billing_info("projects/"+@cloud_id) + if cur_billing and cur_billing.billing_account_name + bok['billing_acct'] = cur_billing.billing_account_name.sub(/^billingAccounts\//, '') + end + end + + bok + end + + # Cloud-specific configuration properties. # @param config [MU::Config]: The calling MU::Config object # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource @@ -221,6 +342,10 @@ def self.schema(config) "billing_acct" => { "type" => "string", "description" => "Billing account ID to associate with a newly-created Google Project. If not specified, will attempt to locate a billing account associated with the default project for our credentials." + }, + "display_name" => { + "type" => "string", + "description" => "A human readable name for this project. If not specified, will default to our long-form deploy-generated name." } } [toplevel_required, schema] @@ -234,7 +359,7 @@ def self.validateConfig(habitat, configurator) ok = true if !MU::Cloud::Google.getOrg(habitat['credentials']) - MU.log "Cannot manage Google Cloud projects in environments without an organization. See also: https://cloud.google.com/resource-manager/docs/creating-managing-organization", MU::ERR + MU.log "Cannot manage Google Cloud folders in environments without an organization", MU::ERR ok = false end diff --git a/modules/mu/clouds/google/loadbalancer.rb b/modules/mu/clouds/google/loadbalancer.rb index be55ce31a..eaaeabcbe 100644 --- a/modules/mu/clouds/google/loadbalancer.rb +++ b/modules/mu/clouds/google/loadbalancer.rb @@ -18,41 +18,18 @@ class Google # A load balancer as configured in {MU::Config::BasketofKittens::loadbalancers} class LoadBalancer < MU::Cloud::LoadBalancer - @project_id = nil - @deploy = nil @lb = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id attr_reader :targetgroups - @cloudformation_data = {} - attr_reader :cloudformation_data - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::loadbalancers} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - if !mu_name.nil? - @mu_name = mu_name - @config['project'] ||= MU::Cloud::Google.defaultProject(@config['credentials']) - if !@project_id - project = MU::Cloud::Google.projectLookup(@config['project'], @deploy, sibling_only: true, raise_on_fail: false) - @project_id = project.nil? ? @config['project'] : project.cloudobj.cloud_id - end - elsif @config['scrub_mu_isms'] - @mu_name = @config['name'] - else - @mu_name = @deploy.getResourceName(@config["name"]) - end + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + @mu_name ||= @deploy.getResourceName(@config["name"]) end # Called automatically by {MU::Deploy#createResources} def create - @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloudobj.cloud_id - parent_thread_id = Thread.current.object_id backends = {} @@ -81,7 +58,7 @@ def create if !@config["private"] #TODO ip_address, port_range, target realproto = ["HTTP", "HTTPS"].include?(l['lb_protocol']) ? l['lb_protocol'] : "TCP" - ruleobj = ::Google::Apis::ComputeBeta::ForwardingRule.new( + ruleobj = ::Google::Apis::ComputeV1::ForwardingRule.new( name: MU::Cloud::Google.nameStr(@mu_name+"-"+l['targetgroup']), description: @deploy.deploy_id, load_balancing_scheme: "EXTERNAL", @@ -91,7 +68,7 @@ def create ) else # TODO network, subnetwork, port_range, target - ruleobj = ::Google::Apis::ComputeBeta::ForwardingRule.new( + ruleobj = ::Google::Apis::ComputeV1::ForwardingRule.new( name: MU::Cloud::Google.nameStr(@mu_name+"-"+l['targetgroup']), description: @deploy.deploy_id, load_balancing_scheme: "INTERNAL", @@ -118,10 +95,6 @@ def create end - # Wrapper that fetches the API's description of one of these things - def cloud_desc - end - # Return the metadata for this LoadBalancer # @return [Hash] def notify @@ -175,6 +148,7 @@ def self.quality # @return [void] def self.cleanup(noop: false, ignoremaster: false, region: nil, credentials: nil, flags: {}) flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) + return if !MU::Cloud::Google::Habitat.isLive?(flags["project"], credentials) if region ["forwarding_rule", "region_backend_service"].each { |type| @@ -192,6 +166,7 @@ def self.cleanup(noop: false, ignoremaster: false, region: nil, credentials: nil MU::Cloud::Google.compute(credentials: credentials).delete( type, flags["project"], + nil, noop ) } diff --git a/modules/mu/clouds/google/role.rb b/modules/mu/clouds/google/role.rb new file mode 100644 index 000000000..9ec821034 --- /dev/null +++ b/modules/mu/clouds/google/role.rb @@ -0,0 +1,1209 @@ +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module MU + class Cloud + class Google + # A role as configured in {MU::Config::BasketofKittens::roles} + class Role < MU::Cloud::Role + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + + @mu_name ||= if !@config['scrub_mu_isms'] + @deploy.getResourceName(@config["name"]) + else + @config['name'] + end + + # If we're being reverse-engineered from a cloud descriptor, use that + # to determine what sort of account we are. + if args[:from_cloud_desc] + @cloud_desc_cache = args[:from_cloud_desc] + if args[:from_cloud_desc].class == ::Google::Apis::AdminDirectoryV1::Role + @config['role_source'] = "directory" + elsif args[:from_cloud_desc].name.match(/^roles\/(.*)/) or + (@cloud_id and @cloud_id.match(/^roles\/(.*)/)) + @config['role_source'] = "canned" + @config['name'] = Regexp.last_match[1] + elsif args[:from_cloud_desc].name.match(/^organizations\/\d+\/roles\/(.*)/) or + (@cloud_id and @cloud_id.match(/^organizations\/\d+\/roles\/(.*)/)) + @config['role_source'] = "org" + @config['name'] = Regexp.last_match[1] + elsif args[:from_cloud_desc].name.match(/^projects\/([^\/]+?)\/roles\/(.*)/) or + (@cloud_id and @cloud_id.match(/^projects\/\d+\/roles\/(.*)/)) + @config['project'] = Regexp.last_match[1] + @config['name'] = Regexp.last_match[2] + @project_id = @config['project'] + @config['role_source'] = "project" + else + MU.log "I don't know what to do with this #{args[:from_cloud_desc].class.name}", MU::ERR, details: args[:from_cloud_desc] + raise MuError, "I don't know what to do with this #{args[:from_cloud_desc].class.name}" + end + end + end + + # Called automatically by {MU::Deploy#createResources} + def create + @config['display_name'] ||= @mu_name + if @config['role_source'] == "directory" + role_obj = MU::Cloud::Google.admin_directory(:Role).new( + role_name: @mu_name, + role_description: @config['display_name'], + role_privileges: MU::Cloud::Google::Role.map_directory_privileges(@config['import'], credentials: @credentials).first + ) + MU.log "Creating directory role #{@mu_name}", details: role_obj + + resp = MU::Cloud::Google.admin_directory(credentials: @credentials).insert_role(@customer, role_obj) + @cloud_id = resp.role_id.to_s + + elsif @config['role_source'] == "canned" + @cloud_id = @config['name'] + if !@cloud_id.match(/^roles\//) + @cloud_id = "roles/"+@cloud_id + end + else + create_role_obj = MU::Cloud::Google.iam(:CreateRoleRequest).new( + role: MU::Cloud::Google.iam(:Role).new( + title: @config['display_name'], + description: @config['description'] + ), + role_id: MU::Cloud::Google.nameStr(@deploy.getResourceName(@config["name"], max_length: 64)).gsub(/[^a-zA-Z0-9_\.]/, "_") + ) + + resp = if @config['role_source'] == "org" + my_org = MU::Cloud::Google.getOrg(@config['credentials']) + MU.log "Creating IAM organization role #{@mu_name} in #{my_org.display_name}", details: create_role_obj + resp = MU::Cloud::Google.iam(credentials: @credentials).create_organization_role(my_org.name, create_role_obj) + elsif @config['role_source'] == "project" + if !@project_id + raise MuError, "Role #{@mu_name} is supposed to be in project #{@config['project']}, but no such project was found" + end + MU.log "Creating IAM project role #{@mu_name} in #{@project_id}", details: create_role_obj + MU::Cloud::Google.iam(credentials: @credentials).create_project_role("projects/"+@project_id, create_role_obj) + end + + @cloud_id = resp.name + + end + end + + # Called automatically by {MU::Deploy#createResources} + def groom + if @config['role_source'] == "directory" +# MU.log "Updating directory role #{@mu_name}", MU::NOTICE, details: role_obj +# MU::Cloud::Google.admin_directory(credentials: @credentials).patch_role(@customer, @cloud_id, role_obj) + elsif @config['role_source'] == "org" + elsif @config['role_source'] == "project" + elsif @config['role_source'] == "canned" + end + + @config['bindings'].each { |binding| + binding.keys.each { |scopetype| + next if scopetype == "entity" + binding[scopetype].each { |scope| +# XXX handle entity being a MU::Config::Ref + entity_id = if binding["entity"]["name"] + sib = @deploy.findLitterMate(name: binding["entity"]["name"], type: binding["entity"]["type"]) + raise MuError, "Failed to look up sibling #{binding["entity"]["type"]}:#{binding["entity"]["name"]}" if !sib + if binding["entity"]["type"] == "users" and sib.config["type"] == "service" + binding["entity"]["type"] = "serviceAccount" + end + sib.cloud_id + else + binding["entity"]["id"] + end +# XXX resolve scope as well, if it's named or a MU::Config::Ref + bindToIAM(binding["entity"]["type"], entity_id.sub(/.*?\/([^\/]+)$/, '\1'), scopetype, scope["id"]) + } + } + } + end + + # Return the cloud descriptor for the Role + # @return [Google::Apis::Core::Hashable] + def cloud_desc + return @cloud_desc_cache if @cloud_desc_cache + + my_org = MU::Cloud::Google.getOrg(@config['credentials']) + + @cloud_desc_cache = if @config['role_source'] == "directory" + MU::Cloud::Google.admin_directory(credentials: @config['credentials']).get_role(@customer, @cloud_id) + elsif @config['role_source'] == "canned" + MU::Cloud::Google.iam(credentials: @config['credentials']).get_role(@cloud_id) + elsif @config['role_source'] == "project" + MU::Cloud::Google.iam(credentials: @config['credentials']).get_project_role(@cloud_id) + elsif @config['role_source'] == "org" + MU::Cloud::Google.iam(credentials: @config['credentials']).get_organization_role(@cloud_id) + end + + @cloud_desc_cache + end + + # Return the metadata for this group configuration + # @return [Hash] + def notify + base = MU.structToHash(cloud_desc) + base.delete(:etag) + base["cloud_id"] = @cloud_id + + base + end + + # Wrapper for #{MU::Cloud::Google::Role.bindToIAM} + def bindToIAM(entity_type, entity_id, scope_type, scope_id) + MU::Cloud::Google::Role.bindToIAM(@cloud_id, entity_type, entity_id, scope_type, scope_id, credentials: @config['credentials']) + end + + @@role_bind_semaphore = Mutex.new + @@role_bind_scope_semaphores = {} + + # Attach a role to an entity + # @param role_id [String]: The cloud identifier of the role to which we're binding + # @param entity_type [String]: The kind of entity to bind; typically user, group, or domain + # @param entity_id [String]: The cloud identifier of the entity + # @param scope_type [String]: The kind of scope in which this binding will be valid; typically project, folder, or organization + # @param scope_id [String]: The cloud identifier of the scope in which this binding will be valid + # @param credentials [String]: + def self.bindToIAM(role_id, entity_type, entity_id, scope_type, scope_id, credentials: nil, debug: false) + loglevel = debug ? MU::NOTICE : MU::DEBUG + + MU.log "Google::Role.bindToIAM(role_id: #{role_id}, entity_type: #{entity_type}, entity_id: #{entity_id}, scope_type: #{scope_type}, scope_id: #{scope_id}, credentials: #{credentials})", loglevel + + # scope_id might actually be the name of a credential set; if so, we + # map it back to an actual organization on the fly + if scope_type == "organizations" + my_org = MU::Cloud::Google.getOrg(scope_id) + if my_org + scope_id = my_org.name + end + end + + @@role_bind_semaphore.synchronize { + @@role_bind_scope_semaphores[scope_id] ||= Mutex.new + } + + @@role_bind_scope_semaphores[scope_id].synchronize { + entity = entity_type.sub(/s$/, "")+":"+entity_id + policy = if scope_type == "organizations" + MU::Cloud::Google.resource_manager(credentials: credentials).get_organization_iam_policy(scope_id) + elsif scope_type == "folders" + MU::Cloud::Google.resource_manager(credentials: credentials).get_folder_iam_policy(scope_id) + elsif scope_type == "projects" + if !scope_id + raise MuError, "Google::Role.bindToIAM was called without a scope_id" + elsif scope_id.is_a?(Hash) + if scope_id["id"] + scope_id = scope_id["id"] + else + raise MuError, "Google::Role.bindToIAM was called with a scope_id Ref hash that has no id field" + end + end + MU::Cloud::Google.resource_manager(credentials: credentials).get_project_iam_policy(scope_id.sub(/^projects\//, "")) + else + puts "WTF DO WIT #{scope_type}" + end + + saw_role = false + policy.bindings.each { |binding| + if binding.role == role_id + saw_role = true + if binding.members.include?(entity) + return # it's already bound, nothing needs doing + else + binding.members << entity + end + end + } + if !saw_role + policy.bindings << MU::Cloud::Google.resource_manager(:Binding).new( + role: role_id, + members: [entity] + ) + end + MU.log "Granting #{role_id} to #{entity} in #{scope_id}", MU::NOTICE + req_obj = MU::Cloud::Google.resource_manager(:SetIamPolicyRequest).new( + policy: policy + ) + policy = if scope_type == "organizations" + MU::Cloud::Google.resource_manager(credentials: credentials).set_organization_iam_policy( + scope_id, + req_obj + ) + elsif scope_type == "folders" + MU::Cloud::Google.resource_manager(credentials: credentials).set_folder_iam_policy( + scope_id, + req_obj + ) + elsif scope_type == "projects" + MU::Cloud::Google.resource_manager(credentials: credentials).set_project_iam_policy( + scope_id, + req_obj + ) + end + } + end + + # Remove all bindings for the specified entity + # @param entity_type [String]: The kind of entity to bind; typically user, group, or domain + # @param entity_id [String]: The cloud identifier of the entity + # @param credentials [String]: + # @param noop [Boolean]: Just say what we'd do without doing it + def self.removeBindings(entity_type, entity_id, credentials: nil, noop: false) + + scopes = {} + + my_org = MU::Cloud::Google.getOrg(credentials) + if my_org + scopes["organizations"] = [my_org.name] + folders = MU::Cloud::Google::Folder.find(credentials: credentials) + if folders and folders.size > 0 + scopes["folders"] = folders.keys + end + end + + projects = MU::Cloud::Google::Habitat.find(credentials: credentials) + if projects and projects.size > 0 + scopes["projects"] = projects.keys + end + + scopes.each_pair { |scope_type, scope_ids| + scope_ids.each { |scope_id| + @@role_bind_semaphore.synchronize { + @@role_bind_scope_semaphores[scope_id] ||= Mutex.new + } + + @@role_bind_scope_semaphores[scope_id].synchronize { + entity = entity_type.sub(/s$/, "")+":"+entity_id + policy = if scope_type == "organizations" + MU::Cloud::Google.resource_manager(credentials: credentials).get_organization_iam_policy(my_org.name) + elsif scope_type == "folders" + MU::Cloud::Google.resource_manager(credentials: credentials).get_folder_iam_policy(scope_id) + elsif scope_type == "projects" + MU::Cloud::Google.resource_manager(credentials: credentials).get_project_iam_policy(scope_id) + end + + need_update = false + policy.bindings.each { |binding| + if binding.members.include?(entity) + MU.log "Unbinding #{binding.role} from #{entity} in #{scope_id}" + need_update = true + binding.members.delete(entity) + end + } +# XXX maybe drop bindings with 0 members? + next if !need_update or noop + req_obj = MU::Cloud::Google.resource_manager(:SetIamPolicyRequest).new( + policy: policy + ) + + policy = if scope_type == "organizations" + MU::Cloud::Google.resource_manager(credentials: credentials).set_organization_iam_policy( + scope_id, + req_obj + ) + elsif scope_type == "folders" + MU::Cloud::Google.resource_manager(credentials: credentials).set_folder_iam_policy( + scope_id, + req_obj + ) + elsif scope_type == "projects" + MU::Cloud::Google.resource_manager(credentials: credentials).set_project_iam_policy( + scope_id, + req_obj + ) + end + } + + } + } + end + + # Add role bindings for a given entity from its BoK config + # @param entity_type [String]: The kind of entity to bind; typically user, group, or domain + # @param entity_id [String]: The cloud identifier of the entity + # @param cfg [Hash]: A configuration block confirming to our own {MU::Cloud::Google::Role.ref_schema} + # @param credentials [String]: + def self.bindFromConfig(entity_type, entity_id, cfg, credentials: nil, deploy: nil, debug: false) + loglevel = debug ? MU::NOTICE : MU::DEBUG + + bindings = [] + + return if !cfg + MU.log "Google::Role::bindFromConfig binding called for #{entity_type} #{entity_id}", loglevel, details: cfg + + cfg.each { |binding| + if deploy and binding["role"]["name"] and !binding["role"]["id"] + role_obj = deploy.findLitterMate(name: binding["role"]["name"], type: "roles") + binding["role"]["id"] = role_obj.cloud_id if role_obj + end + ["organizations", "projects", "folders"].each { |scopetype| + next if !binding[scopetype] + + binding[scopetype].each { |scope| +# XXX resolution of Ref bits (roles, projects, and folders anyway; organizations and domains are direct) + MU::Cloud::Google::Role.bindToIAM( + binding["role"]["id"], + entity_type, + entity_id, + scopetype, + scope, + credentials: credentials + ) + } + } + if binding["directories"] + binding["directories"].each { |dir| + # this is either an organization cloud_id, or the name of one + # of our credential sets, which we must map to an organization + # cloud id + creds = MU::Cloud::Google.credConfig(dir) + + customer = if creds + my_org = MU::Cloud::Google.getOrg(dir) + if !my_org + raise MuError, "Google directory role binding specified directory #{dir}, which looks like one of our credential sets, but does not appear to map to an organization!" + end + my_org.owner.directory_customer_id + elsif dir.match(/^organizations\//) + # Not sure if there's ever a case where we can do this with + # an org that's different from the one our credentials go with + my_org = MU::Cloud::Google.getOrg(credentials, with_id: dir) + if !my_org + raise MuError, "Failed to retrieve #{dir} with credentials #{credentials} in Google directory role binding for role #{binding["role"].to_s}" + end + my_org.owner.directory_customer_id + else + # assume it's a raw customer id and hope for the best + dir + end + + if !binding["role"]["id"].match(/^\d+$/) + resp = MU::Cloud::Google.admin_directory(credentials: credentials).list_roles(customer) + if resp and resp.items + resp.items.each { |role| + if role.role_name == binding["role"]["id"] + binding["role"]["id"] = role.role_id + break + end + } + end + end + + # Ensure we're using the stupid internal id, instead of the + # email field (which is the "real" id most of the time) + real_id = nil + if entity_type == "group" + found = MU::Cloud::Google::Group.find(cloud_id: entity_id, credentials: credentials) + if found[entity_id] + real_id = found[entity_id].id + end + elsif entity_type == "user" + found = MU::Cloud::Google::User.find(cloud_id: entity_id, credentials: credentials) + if found[entity_id] + real_id = found[entity_id].id + end + else + raise MuError, "I don't know how to identify entity type #{entity_type} with id #{entity_id} in directory role binding" + end + real_id ||= entity_id # fingers crossed + + assign_obj = MU::Cloud::Google.admin_directory(:RoleAssignment).new( + assigned_to: real_id, + role_id: binding["role"]["id"], + scope_type: "CUSTOMER" + ) +# XXX guard this mess + MU.log "Binding directory role #{(binding["role"]["name"] || binding["role"]["id"])} to #{entity_type} #{entity_id} in #{dir}", details: assign_obj + MU::Cloud::Google.admin_directory(credentials: credentials).insert_role_assignment( + customer, + assign_obj + ) + + } + end + } + +# XXX whattabout GSuite-tier roles? + end + + # Does this resource type exist as a global (cloud-wide) artifact, or + # is it localized to a region/zone? + # @return [Boolean] + def self.isGlobal? + true + end + + # Return the list of "container" resource types in which this resource + # can reside. The list will include an explicit nil if this resource + # can exist outside of any container. + # @return [Array] + def self.canLiveIn + [nil, :Habitat, :Folder] + end + + # Denote whether this resource implementation is experiment, ready for + # testing, or ready for production use. + def self.quality + MU::Cloud::RELEASE + end + + # Remove all roles associated with the currently loaded deployment. + # @param noop [Boolean]: If true, will only print what would be done + # @param ignoremaster [Boolean]: If true, will remove resources not flagged as originating from this Mu server + # @param region [String]: The cloud provider region + # @return [void] + def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) + customer = MU::Cloud::Google.customerID(credentials) + my_org = MU::Cloud::Google.getOrg(credentials) + + if flags['known'] + flags['known'].each { |id| + next if id.nil? + # GCP roles don't have a useful field for packing in our deploy + # id, so if we have metadata to leverage for this, use it. For + # directory roles, we try to make it into the name field, so + # we'll check that later, but for org and project roles this is + # our only option. + if my_org and id.is_a?(Integer) or id.match(/^\d+/) + begin + resp = MU::Cloud::Google.admin_directory(credentials: credentials).get_role(customer, id) + rescue ::Google::Apis::ClientError => e + next if e.message.match(/notFound/) + raise e + end + if resp + MU.log "Deleting directory role #{resp.role_name}" + if !noop + MU::Cloud::Google.admin_directory(credentials: credentials).delete_role(customer, id) + end + end + elsif id.match(/^projects\/.*?\/roles\//) + begin + resp = MU::Cloud::Google.iam(credentials: credentials).get_project_role(id) + rescue ::Google::Apis::ClientError => e + next if e.message.match(/notFound/) + raise e + end + if resp + MU.log "Deleting project role #{resp.name}" + if !noop + MU::Cloud::Google.iam(credentials: credentials).delete_project_role(id) + end + end + elsif id.match(/^organizations\//) + begin + resp = MU::Cloud::Google.iam(credentials: credentials).get_organization_role(id) + rescue ::Google::Apis::ClientError => e +#MU.log e.message, MU::ERR, details: id +#next + next if e.message.match(/notFound/) + raise e + end + if resp + MU.log "Deleting organization role #{resp.name}" + if !noop + MU::Cloud::Google.iam(credentials: credentials).delete_organization_role(id) + end + end + end + } + end + + if my_org and MU.deploy_id and !MU.deploy_id.empty? + resp = MU::Cloud::Google.admin_directory(credentials: credentials).list_roles(customer) + if resp and resp.items + resp.items.each { |role| + if role.role_name.match(/^#{Regexp.quote(MU.deploy_id)}/) + MU.log "Deleting directory role #{role.role_name}" + if !noop + MU::Cloud::Google.admin_directory(credentials: credentials).delete_role(customer, role.role_id) + end + end + } + end + end + + end + + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) + credcfg = MU::Cloud::Google.credConfig(args[:credentials]) + customer = MU::Cloud::Google.customerID(args[:credentials]) + my_org = MU::Cloud::Google.getOrg(args[:credentials]) + + found = {} + args[:project] ||= args[:habitat] + + if args[:project] + canned = Hash[MU::Cloud::Google.iam(credentials: args[:credentials]).list_roles.roles.map { |r| [r.name, r] }] + begin + MU::Cloud::Google::Habitat.bindings(args[:project], credentials: args[:credentials]).each { |binding| + found[binding.role] = canned[binding.role] + } + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/forbidden: /) + end + + resp = begin + MU::Cloud::Google.iam(credentials: args[:credentials]).list_project_roles("projects/"+args[:project]) + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/forbidden: /) + end + if resp and resp.roles + resp.roles.each { |role| + found[role.name] = role + } + end + if args[:cloud_id] + found.reject! { |k, v| k != role.name } + end + + # Now go get everything that's bound here + bindings = MU::Cloud::Google::Role.getAllBindings(args[:credentials]) + if bindings and bindings['by_scope'] and + bindings['by_scope']['projects'] and + bindings['by_scope']['projects'][args[:project]] + bindings['by_scope']['projects'][args[:project]].keys.each { |r| + if r.match(/^roles\//) + role = MU::Cloud::Google.iam(credentials: args[:credentials]).get_role(r) + found[role.name] = role + elsif !found[r] + MU.log "NEED TO GET #{r}", MU::WARN + end + } + end + else + if credcfg['masquerade_as'] + if args[:cloud_id] + begin + resp = MU::Cloud::Google.admin_directory(credentials: args[:credentials]).get_role(customer, args[:cloud_id].to_i) + if resp + found[args[:cloud_id].to_s] = resp + end + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/(?:forbidden|notFound): /) + end + else + resp = MU::Cloud::Google.admin_directory(credentials: args[:credentials]).list_roles(customer) + if resp and resp.items + resp.items.each { |role| + found[role.role_id.to_s] = role + } + end + end + + end +# These are the canned roles + resp = begin + MU::Cloud::Google.iam(credentials: args[:credentials]).list_roles + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/forbidden: /) + end + if resp + resp.roles.each { |role| + found[role.name] = role + } + end + + resp = begin + MU::Cloud::Google.iam(credentials: args[:credentials]).list_organization_roles(my_org.name) + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/forbidden: /) + end + if resp and resp.roles + resp.roles.each { |role| + found[role.name] = role + } + end + end + + found + end + + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + def toKitten(rootparent: nil, billing: nil, habitats: nil) + bok = { + "cloud" => "Google", + "credentials" => @config['credentials'], + "cloud_id" => @cloud_id + } + + my_org = MU::Cloud::Google.getOrg(@config['credentials']) + + # This can happen if the role_source isn't set correctly. This logic + # maybe belongs inside cloud_desc. XXX + if cloud_desc.nil? + if @cloud_id and @cloud_id.match(/^roles\/(.*)/) + @config['role_source'] = "canned" + elsif @cloud_id and @cloud_id.match(/^organizations\/\d+\/roles\/(.*)/) + @config['role_source'] = "org" + elsif @cloud_id and @cloud_id.match(/^projects\/\d+\/roles\/(.*)/) + @config['role_source'] = "project" + end + end + + # GSuite or Cloud Identity role + if cloud_desc.class == ::Google::Apis::AdminDirectoryV1::Role + return nil if cloud_desc.is_system_role + + bok["name"] = @config['name'].gsub(/[^a-z0-9]/i, '-').downcase + bok['role_source'] = "directory" + bok["display_name"] = @config['name'] + if !cloud_desc.role_description.empty? + bok["description"] = cloud_desc.role_description + end + if !cloud_desc.role_privileges.nil? and !cloud_desc.role_privileges.empty? + bok['import'] = [] + ids, names, privs = MU::Cloud::Google::Role.privilege_service_to_name(@config['credentials']) + cloud_desc.role_privileges.each { |priv| + if !ids[priv.service_id] + MU.log "Role privilege defined for a service id with no name I can find, writing with raw id", MU::WARN, details: priv + bok["import"] << priv.service_id+"/"+priv.privilege_name + else + bok["import"] << ids[priv.service_id]+"/"+priv.privilege_name + end + } + bok['import'].sort! # at least be legible + end + else # otherwise it's a GCP IAM role of some kind + + return nil if cloud_desc.stage == "DISABLED" + if cloud_desc.name.match(/^roles\/([^\/]+)$/) + name = Regexp.last_match[1] + bok['name'] = name.gsub(/[^a-z0-9]/i, '-') + bok['role_source'] = "canned" + elsif cloud_desc.name.match(/^([^\/]+?)\/([^\/]+?)\/roles\/(.*)/) + junk, type, parent, name = Regexp.last_match.to_a + bok['name'] = name.gsub(/[^a-z0-9]/i, '-') + bok['role_source'] = type == "organizations" ? "org" : "project" + if bok['role_source'] == "project" + bok['project'] = parent + end + if cloud_desc.included_permissions and cloud_desc.included_permissions.size > 0 + bok['import'] = cloud_desc.included_permissions + end + + else + raise MuError, "I don't know how to parse GCP IAM role identifier #{cloud_desc.name}" + end + + if !cloud_desc.description.nil? and !cloud_desc.description.empty? + bok["description"] = cloud_desc.description + end + bok["display_name"] = cloud_desc.title + + bindings = MU::Cloud::Google::Role.getAllBindings(@config['credentials'])["by_role"][@cloud_id] + + if bindings + refmap = {} + bindings.keys.each { |scopetype| + bindings[scopetype].each_pair { |scope_id, entity_types| + # If we've been given a habitat filter, skip over bindings + # that don't match it. + if scopetype == "projects" and habitats and + !habitats.empty? and !habitats.include?(scope_id) + next + end + + entity_types.each_pair { |entity_type, entities| + mu_entitytype = (entity_type == "serviceAccount" ? "user" : entity_type)+"s" + entities.each { |entity| + entity_ref = if entity_type == "organizations" + { "id" => ((org == my_org.name and @config['credentials']) ? @config['credentials'] : org) } + elsif entity_type == "domain" + { "id" => entity } + else + MU::Config::Ref.get( + id: entity, + cloud: "Google", + type: mu_entitytype + ) + end + refmap ||= {} + refmap[entity_ref] ||= {} + refmap[entity_ref][scopetype] ||= [] + mu_scopetype = scopetype == "projects" ? "habitats" : scopetype + if scopetype == "organizations" or scopetype == "domains" # XXX singular? plural? barf + refmap[entity_ref][scopetype] << ((scope_id == my_org.name and @config['credentials']) ? @config['credentials'] : scope_id) + else + refmap[entity_ref][scopetype] << MU::Config::Ref.get( + id: scope_id, + cloud: "Google", + type: mu_scopetype + ) + end + refmap[entity_ref][scopetype].uniq! + } + } + } + } + bok["bindings"] ||= [] + refmap.each_pair { |entity, scopes| + newbinding = { "entity" => entity } + scopes.keys.each { |scopetype| + newbinding[scopetype] = scopes[scopetype].sort + } + bok["bindings"] << newbinding + } + end + end + + # Our only reason for declaring canned roles is so we can put their + # bindings somewhere. If there aren't any, then we don't need + # to bother with them. + if bok['role_source'] == "canned" and + (bok['bindings'].nil? or bok['bindings'].empty?) + return nil + end + + bok + end + + + # Schema used by +user+ and +group+ entities to reference role + # assignments and their scopes. + # @return [] + def self.ref_schema + { + "type" => "object", + "description" => "One or more Google IAM roles to associate with this entity. IAM roles in Google can be associated at the project (+Habitat+), folder, or organization level, so we must specify not only role, but each container in which it is granted to the entity in question.", + "properties" => { + "role" => MU::Config::Ref.schema(type: "roles"), + "projects" => { + "type" => "array", + "items" => MU::Config::Ref.schema(type: "habitats") + }, + "folders" => { + "type" => "array", + "items" => MU::Config::Ref.schema(type: "folders") + }, + "organizations" => { + "type" => "array", + "items" => { + "type" => "string", + "description" => "Either an organization cloud identifier, like +organizations/123456789012+, or the name of set of Mu credentials listed in +mu.yaml+, which can be used as an alias to the organization to which they authenticate." + } + } + } + } + end + + @@binding_semaphore = Mutex.new + @@bindings_by_role = {} + @@bindings_by_entity = {} + @@bindings_by_scope = {} + + # Retrieve IAM role bindings for all entities throughout our + # organization, map them in useful ways, and cache the result. + def self.getAllBindings(credentials = nil, refresh: false) + my_org = MU::Cloud::Google.getOrg(credentials) + @@binding_semaphore.synchronize { + if @@bindings_by_role.size > 0 and !refresh + return { + "by_role" => @@bindings_by_role.dup, + "by_scope" => @@bindings_by_scope.dup, + "by_entity" => @@bindings_by_entity.dup + } + end + + def self.insertBinding(scopetype, scope, binding = nil, member_type: nil, member_id: nil, role_id: nil) + role_id = binding.role if binding + @@bindings_by_scope[scopetype] ||= {} + @@bindings_by_scope[scopetype][scope] ||= {} + @@bindings_by_scope[scopetype][scope][role_id] ||= {} + @@bindings_by_role[role_id] ||= {} + @@bindings_by_role[role_id][scopetype] ||= {} + @@bindings_by_role[role_id][scopetype][scope] ||= {} + + do_binding = Proc.new { |type, id| + @@bindings_by_role[role_id][scopetype][scope][type] ||= [] + @@bindings_by_role[role_id][scopetype][scope][type] << id + @@bindings_by_scope[scopetype][scope][role_id][type] ||= [] + @@bindings_by_scope[scopetype][scope][role_id][type] << id + @@bindings_by_entity[type] ||= {} + @@bindings_by_entity[type][id] ||= {} + @@bindings_by_entity[type][id][role_id] ||= {} + @@bindings_by_entity[type][id][role_id][scopetype] ||= [] + @@bindings_by_entity[type][id][role_id][scopetype] << scope + } + + if binding + binding.members.each { |member| + member_type, member_id = member.split(/:/) + do_binding.call(member_type, member_id) + } + elsif member_type and member_id + do_binding.call(member_type, member_id) + end + + end + + if my_org + resp = MU::Cloud::Google.admin_directory(credentials: credentials).list_role_assignments(MU::Cloud::Google.customerID(credentials)) + + resp.items.each { |binding| + + begin + user = MU::Cloud::Google.admin_directory(credentials: credentials).get_user(binding.assigned_to) + insertBinding("directories", my_org.name, member_id: user.primary_email, member_type: "user", role_id: binding.role_id.to_s) + next + rescue ::Google::Apis::ClientError # notFound + end + + begin + group = MU::Cloud::Google.admin_directory(credentials: credentials).get_group(binding.assigned_to) + MU.log "GROUP", MU::NOTICE, details: group +# insertBinding("directories", my_org.name, member_id: group.primary_email, member_type: "group", role_id: binding.role_id.to_s) + next + rescue ::Google::Apis::ClientError # notFound + end + + role = MU::Cloud::Google.admin_directory(credentials: credentials).get_role(MU::Cloud::Google.customerID(credentials), binding.role_id) + MU.log "Failed to find entity #{binding.assigned_to} referenced in GSuite/Cloud Identity binding to role #{role.role_name}", MU::WARN, details: role + } + + resp = MU::Cloud::Google.resource_manager(credentials: credentials).get_organization_iam_policy(my_org.name) + resp.bindings.each { |binding| + insertBinding("organizations", my_org.name, binding) + } + + MU::Cloud::Google::Folder.find(credentials: credentials).keys.each { |folder| + MU::Cloud::Google::Folder.bindings(folder, credentials: credentials).each { |binding| + insertBinding("folders", folder, binding) + } + } + end + MU::Cloud::Google::Habitat.find(credentials: credentials).keys.each { |project| + begin + MU::Cloud::Google::Habitat.bindings(project, credentials: credentials).each { |binding| + insertBinding("projects", project, binding) + } + rescue ::Google::Apis::ClientError => e + if e.message.match(/forbidden: /) + MU.log "Do not have permissions to retrieve bindings in project #{project}, skipping", MU::WARN + else + raise e + end + end + + } + + return { + "by_role" => @@bindings_by_role.dup, + "by_scope" => @@bindings_by_scope.dup, + "by_entity" => @@bindings_by_entity.dup + } + } + end + + # Convert a list of bindings of the type returned by {MU::Cloud::Google::Role.getAllBindings} into valid configuration language. + # @param roles [Hash] + # @param credentials [String] + # @return [Hash] + def self.entityBindingsToSchema(roles, credentials: nil) + my_org = MU::Cloud::Google.getOrg(credentials) + role_cfg = [] + + roles.each_pair { |role, scopes| + rolemap = { } + rolemap["role"] = if !role.is_a?(Integer) and role.match(/^roles\//) + # generally referring to a canned GCP role + { "id" => role.to_s } + elsif role.is_a?(Integer) or role.match(/^\d+$/) + # If this is a GSuite/Cloud Identity system role, reference it by + # its human-readable name intead of its numeric id + role_desc = MU::Cloud::Google::Role.find(cloud_id: role, credentials: credentials).values.first + if role_desc.is_system_role + { "id" => role_desc.role_name } + else + MU::Config::Ref.get( + id: role, + cloud: "Google", + credentials: credentials, + type: "roles" + ) + end + else + # Possi-probably something we're declaring elsewhere in this + # adopted Mu stack + MU::Config::Ref.get( + id: role, + cloud: "Google", + credentials: credentials, + type: "roles" + ) + end + scopes.each_pair { |scopetype, places| + if places.size > 0 + rolemap[scopetype] = [] + if scopetype == "organizations" or scopetype == "directories" + places.each { |org| + rolemap[scopetype] << ((org == my_org.name and credentials) ? credentials : org) + } + else + places.each { |place| + mu_type = scopetype == "projects" ? "habitats" : scopetype + rolemap[scopetype] << MU::Config::Ref.get( + id: place, + cloud: "Google", + credentials: credentials, + type: mu_type + ) + } + end + end + } + role_cfg << rolemap + } + + role_cfg + end + + # Cloud-specific configuration properties. + # @param config [MU::Config]: The calling MU::Config object + # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource + def self.schema(config) + toplevel_required = [] + schema = { + "name" => { + "pattern" => '^[a-zA-Z0-9\-\.\/]+$' + }, + "display_name" => { + "type" => "string", + "description" => "A human readable name for this role. If not specified, will default to our long-form deploy-generated name." + }, + "role_source" => { + "type" => "string", + "description" => "Google effectively has four types of roles: + ++directory+: An admin role in GSuite or Cloud Identity + ++org+: A custom organization-level IAM role. Note that these are only valid in GSuite or Cloud Identity environments + ++project+: A custom project-level IAM role. + ++canned+: A reference to one of the standard pre-defined IAM roles, usually only declared to apply {bindings} to other artifacts. + +If this value is not specified, and the role name matches the name of an existing +canned+ role, we will assume it should be +canned+. If it does not, and we have credentials which map to a valid organization, we will assume +org+; if the credentials do not map to an organization, we will assume +project+.", + "enum" => ["directory", "org", "project", "canned"] + }, + "description" => { + "type" => "string", + "description" => "Detailed human-readable description of this role's purpose" + }, + "bindings" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "One or more entities (+user+, +group+, etc) to associate with this role. IAM roles in Google can be associated at the project (+Habitat+), folder, or organization level, so we must specify not only the target entity, but each container in which it is granted to the entity in question.", + "properties" => { + "entity" => MU::Config::Ref.schema, + "projects" => { + "type" => "array", + "items" => MU::Config::Ref.schema(type: "habitats") + }, + "folders" => { + "type" => "array", + "items" => MU::Config::Ref.schema(type: "folders") + }, + "organizations" => { + "type" => "array", + "items" => { + "type" => "string", + "description" => "Either an organization cloud identifier, like +organizations/123456789012+, or the name of set of Mu credentials, which can be used as an alias to the organization to which they authenticate." + } + } + } + } + } + } + [toplevel_required, schema] + end + + # Cloud-specific pre-processing of {MU::Config::BasketofKittens::roles}, bare and unvalidated. + # @param role [Hash]: The resource to process and validate + # @param configurator [MU::Config]: The overall deployment configurator of which this resource is a member + # @return [Boolean]: True if validation succeeded, False otherwise + def self.validateConfig(role, configurator) + ok = true + + credcfg = MU::Cloud::Google.credConfig(role['credentials']) + + my_org = MU::Cloud::Google.getOrg(role['credentials']) + if !role['role_source'] + begin + lookup_name = role['name'].dup + if !lookup_name.match(/^roles\//) + lookup_name = "roles/"+lookup_name + end + canned = MU::Cloud::Google.iam(credentials: role['credentials']).get_role(lookup_name) + MU.log "Role #{role['name']} appears to be a referenced to canned role #{role.name} (#{role.title})", MU::NOTICE + role['role_source'] = "canned" + rescue ::Google::Apis::ClientError + role['role_source'] = my_org ? "org" : "project" + end + end + + if role['role_source'] == "canned" + if role['bindings'].nil? or role['bindings'].empty? + MU.log "Role #{role['name']} appears to refer to a canned role, but does not have any bindings declared- this will effectively do nothing.", MU::WARN + end + end + + if role['role_source'] == "directory" + + if role['import'] and role['import'].size > 0 + mappings, missing = map_directory_privileges(role['import'], credentials: role['credentials']) + if mappings.size == 0 + MU.log "None of the directory service privileges available to credentials #{role['credentials']} map to the ones declared for role #{role['name']}", MU::ERR, details: role['import'].sort + ok = false + elsif missing.size > 0 + MU.log "Some directory service privileges declared for role #{role['name']} aren't available to credentials #{role['credentials']}, will skip", MU::WARN, details: missing + end + end + end + + if role['role_source'] == "directory" or role['role_source'] == "org" + if !my_org + MU.log "Role #{role['name']} requires an organization/directory, but credential set #{role['credentials']} doesn't appear to have access to one", MU::ERR + ok = false + end + end + + if role['role_source'] == "project" + role['project'] ||= MU::Cloud::Google.defaultProject(role['credentials']) + if configurator.haveLitterMate?(role['project'], "habitats") + role['dependencies'] ||= [] + role['dependencies'] << { + "type" => "habitat", + "name" => role['project'] + } + end + end + + if role['bindings'] + role['bindings'].each { |binding| + if binding['entity'] and binding['entity']['name'] and + configurator.haveLitterMate?(binding['entity']['name'], binding['entity']['type']) + role['dependencies'] ||= [] + role['dependencies'] << { + "type" => binding['entity']['type'].sub(/s$/, ''), + "name" => binding['entity']['name'] + } + + end + } + end + + ok + end + + private + + @@service_id_to_name = {} + @@service_id_to_privs = {} + @@service_name_to_id = {} + @@service_name_map_semaphore = Mutex.new + + def self.privilege_service_to_name(credentials = nil) + + customer = MU::Cloud::Google.customerID(credentials) + @@service_name_map_semaphore.synchronize { + if !@@service_id_to_name[credentials] or + !@@service_id_to_privs[credentials] or + !@@service_name_to_id[credentials] + @@service_id_to_name[credentials] ||= {} + @@service_id_to_privs[credentials] ||= {} + @@service_name_to_id[credentials] ||= {} + resp = MU::Cloud::Google.admin_directory(credentials: credentials).list_privileges(customer) + + def self.id_map_recurse(items, parent_name = nil) + id_to_name = {} + name_to_id = {} + id_to_privs = {} + + items.each { |p| + svcname = p.service_name || parent_name + if svcname + id_to_name[p.service_id] ||= svcname + name_to_id[svcname] ||= p.service_id + else +# MU.log "FREAKING #{p.service_id} HAS NO NAME", MU::WARN + end + id_to_privs[p.service_id] ||= [] + id_to_privs[p.service_id] << p.privilege_name + if p.child_privileges + ids, names, privs = id_map_recurse(p.child_privileges, svcname) + id_to_name.merge!(ids) + name_to_id.merge!(names) + privs.each_pair { |id, childprivs| + id_to_privs[id] ||= [] + id_to_privs[id].concat(childprivs) + } + end + } + + [id_to_name, name_to_id, id_to_privs] + end + + @@service_id_to_name[credentials], @@service_id_to_privs[credentials], @@service_name_to_id[credentials] = self.id_map_recurse(resp.items) + end + + return [@@service_id_to_name[credentials], @@service_id_to_privs[credentials], @@service_name_to_id[credentials]] + } + end + + def self.map_directory_privileges(roles, credentials: nil) + rolepriv_objs = [] + notfound = [] + if roles + ids, names, privlist = MU::Cloud::Google::Role.privilege_service_to_name(credentials) + roles.each { |p| + service, privilege = p.split(/\//) + if !names[service] and !ids[service] + notfound << service + elsif !privlist[names[service]].include?(privilege) + notfound << p + elsif names[service] + rolepriv_objs << MU::Cloud::Google.admin_directory(:Role)::RolePrivilege.new( + privilege_name: privilege, + service_id: names[service] + ) + else + rolepriv_objs << MU::Cloud::Google.admin_directory(:Role)::RolePrivilege.new( + privilege_name: privilege, + service_id: service + ) + end + } + end + [rolepriv_objs, notfound.uniq.sort] + end + + end + end + end +end diff --git a/modules/mu/clouds/google/server.rb b/modules/mu/clouds/google/server.rb index cb5a34842..6914cd5de 100644 --- a/modules/mu/clouds/google/server.rb +++ b/modules/mu/clouds/google/server.rb @@ -28,27 +28,19 @@ class Google # Google Cloud, this amounts to a single Instance in an Unmanaged # Instance Group. class Server < MU::Cloud::Server - @project_id = nil - - attr_reader :mu_name - attr_reader :config - attr_reader :deploy - attr_reader :cloud_id - attr_reader :cloud_desc - attr_reader :groomer - attr_accessor :mu_windows_name - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::servers} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id = cloud_id - - if @deploy - @userdata = MU::Cloud.fetchUserdata( + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + + @userdata = if @config['userdata_script'] + @config['userdata_script'] + elsif @deploy and !@config['scrub_mu_isms'] + MU::Cloud.fetchUserdata( platform: @config["platform"], - cloud: "google", + cloud: "Google", + credentials: @config['credentials'], template_variables: { "deployKey" => Base64.urlsafe_encode64(@deploy.public_key), "deploySSHKey" => @deploy.ssh_public_key, @@ -57,6 +49,9 @@ def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) "publicIP" => MU.mu_public_ip, "skipApplyUpdates" => @config['skipinitialupdates'], "windowsAdminName" => @config['windows_admin_username'], + "adminBucketName" => MU::Cloud::Google.adminBucketName(@credentials), + "chefVersion" => MU.chefVersion, + "mommaCatPort" => MU.mommaCatPort, "resourceName" => @config["name"], "resourceType" => "server", "platform" => @config["platform"] @@ -64,17 +59,11 @@ def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) custom_append: @config['userdata_script'] ) end - - if !mu_name.nil? - @mu_name = mu_name - @config['mu_name'] = @mu_name +# XXX writing things into @config at runtime is a bad habit and we should stop + if !@mu_name.nil? + @config['mu_name'] = @mu_name # XXX whyyyy # describe @mu_windows_name = @deploydata['mu_windows_name'] if @mu_windows_name.nil? and @deploydata - @config['project'] ||= MU::Cloud::Google.defaultProject(@config['credentials']) - if !@project_id - project = MU::Cloud::Google.projectLookup(@config['project'], @deploy, sibling_only: true, raise_on_fail: false) - @project_id = project.nil? ? @config['project'] : project.cloudobj.cloud_id - end else if kitten_cfg.has_key?("basis") @mu_name = @deploy.getResourceName(@config['name'], need_unique_string: true) @@ -83,60 +72,88 @@ def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) end @config['mu_name'] = @mu_name - @config['instance_secret'] = Password.random(50) end - @config['ssh_user'] ||= "mu" - @groomer = MU::Groomer.new(self) + @config['instance_secret'] ||= Password.random(50) + @config['ssh_user'] ||= "muadmin" end - # Generate a server-class specific service account, used to grant - # permission to do various API things to a node. - # @param rolename [String]: - # @param project [String]: - # @param scopes [Array]: https://developers.google.com/identity/protocols/googlescopes - # XXX this should be a MU::Cloud::Google::User resource - def self.createServiceAccount(rolename, deploy, project: nil, scopes: ["https://www.googleapis.com/auth/compute.readonly", "https://www.googleapis.com/auth/logging.write", "https://www.googleapis.com/auth/cloud-platform"], credentials: nil) - project ||= MU::Cloud::Google.defaultProject(credentials) - -#https://www.googleapis.com/auth/devstorage.read_only ? - name = deploy.getResourceName(rolename, max_length: 30).downcase - - saobj = MU::Cloud::Google.iam(:CreateServiceAccountRequest).new( - account_id: name.gsub(/[^a-z]/, ""), # XXX this mangling isn't required in the console, so why is it here? - service_account: MU::Cloud::Google.iam(:ServiceAccount).new( - display_name: rolename, -# do NOT specify project_id or name, we know that much - ) - ) - - resp = MU::Cloud::Google.iam(credentials: credentials).create_service_account( - "projects/#{project}", - saobj - ) + # Return the date/time a machine image was created. + # @param image_id [String]: URL to a Google disk image + # @param credentials [String] + # @return [DateTime] + def self.imageTimeStamp(image_id, credentials: nil) + begin + img = fetchImage(image_id, credentials: credentials) + return DateTime.new if img.nil? + return DateTime.parse(img.creation_timestamp) + rescue ::Google::Apis::ClientError => e + end - MU::Cloud::Google.compute(:ServiceAccount).new( - email: resp.email, - scopes: scopes - ) + return DateTime.new end + @@image_id_map = {} + # Retrieve the cloud descriptor for this machine image, which can be # a whole or partial URL. Will follow deprecation notices and retrieve # the latest version, if applicable. # @param image_id [String]: URL to a Google disk image - # @return [Google::Apis::ComputeBeta::Image] + # @param credentials [String] + # @return [Google::Apis::ComputeV1::Image] def self.fetchImage(image_id, credentials: nil) + return @@image_id_map[image_id] if @@image_id_map[image_id] + img_proj = img_name = nil - begin - img_proj = image_id.gsub(/.*?\/?projects\/([^\/]+)\/.*/, '\1') + if image_id.match(/\//) + img_proj = image_id.gsub(/(?:https?:\/\/.*?\.googleapis\.com\/compute\/.*?\/)?.*?\/?(?:projects\/)?([^\/]+)\/.*/, '\1') img_name = image_id.gsub(/.*?([^\/]+)$/, '\1') + else + img_name = image_id + end + + begin + @@image_id_map[image_id] = MU::Cloud::Google.compute(credentials: credentials).get_image_from_family(img_proj, img_name) + return @@image_id_map[image_id] + rescue ::Google::Apis::ClientError + # This is fine- we don't know that what we asked for is really an + # image family name, instead of just an image. + end + + begin img = MU::Cloud::Google.compute(credentials: credentials).get_image(img_proj, img_name) if !img.deprecated.nil? and !img.deprecated.replacement.nil? image_id = img.deprecated.replacement + img_proj = image_id.gsub(/(?:https?:\/\/.*?\.googleapis\.com\/compute\/.*?\/)?.*?\/?(?:projects\/)?([^\/]+)\/.*/, '\1') + img_name = image_id.gsub(/.*?([^\/]+)$/, '\1') + end + rescue ::Google::Apis::ClientError => e + # SOME people *cough* don't use deprecation or image family names + # and just spew out images with a version appended to the name, so + # let's try some crude semantic versioning list. + if e.message.match(/^notFound: /) and img_name.match(/-[^\-]+$/) + list = MU::Cloud::Google.compute(credentials: credentials).list_images(img_proj, filter: "name eq #{img_name.sub(/-[^\-]+$/, '')}-.*") + if list and list.items + latest = nil + list.items.each { |candidate| + created = DateTime.parse(candidate.creation_timestamp) + if latest.nil? or created > latest + latest = created + img = candidate + end + } + if latest + MU.log "Mapped #{image_id} to #{img.name} with semantic versioning guesswork", MU::WARN + @@image_id_map[image_id] = img + return @@image_id_map[image_id] + end + end end + raise e # if our little semantic versioning party trick failed end while !img.deprecated.nil? and img.deprecated.state == "DEPRECATED" and !img.deprecated.replacement.nil? - MU::Cloud::Google.compute(credentials: credentials).get_image(img_proj, img_name) + final = MU::Cloud::Google.compute(credentials: credentials).get_image(img_proj, img_name) + @@image_id_map[image_id] = final + @@image_id_map[image_id] end # Generator for disk configuration parameters for a Compute instance @@ -225,13 +242,16 @@ def self.interfaceConfig(config, vpc) subnet_cfg = config['vpc'] if config['vpc']['subnets'] and !subnet_cfg['subnet_name'] and !subnet_cfg['subnet_id'] + # XXX if illegal subnets somehow creep in here, we'll need to be + # picky by region or somesuch subnet_cfg = config['vpc']['subnets'].sample end subnet = vpc.getSubnet(name: subnet_cfg['subnet_name'], cloud_id: subnet_cfg['subnet_id']) if subnet.nil? - raise MuError, "Couldn't find subnet details while configuring Server #{config['name']} (VPC: #{vpc.mu_name})" + raise MuError, "Couldn't find subnet details for #{subnet_cfg['subnet_name'] || subnet_cfg['subnet_id']} while configuring Server #{config['name']} (VPC: #{vpc.mu_name})" end + base_iface_obj = { :network => vpc.url, :subnetwork => subnet.url @@ -249,15 +269,29 @@ def self.interfaceConfig(config, vpc) # Called automatically by {MU::Deploy#createResources} def create - @project_id = MU::Cloud::Google.projectLookup(@config['project_id'], @deploy).cloudobj.cloud_id + @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloud_id - service_acct = MU::Cloud::Google::Server.createServiceAccount( - @mu_name.downcase, - @deploy, - project: @project_id, - credentials: @config['credentials'] + sa = nil + retries = 0 + begin + sa = MU::Config::Ref.get(@config['service_account']) + if !sa or !sa.kitten or !sa.kitten.cloud_desc + sleep 10 + end + end while !sa or !sa.kitten or !sa.kitten.cloud_desc and retries < 5 + + if !sa or !sa.kitten or !sa.kitten.cloud_desc + raise MuError, "Failed to get service account cloud id from #{@config['service_account'].to_s}" + end + + + @service_acct = MU::Cloud::Google.compute(:ServiceAccount).new( + email: sa.kitten.cloud_desc.email, + scopes: @config['scopes'] ) - MU::Cloud::Google.grantDeploySecretAccess(service_acct.email, credentials: @config['credentials']) + if !@config['scrub_mu_isms'] + MU::Cloud::Google.grantDeploySecretAccess(@service_acct.email, credentials: @config['credentials']) + end begin disks = MU::Cloud::Google::Server.diskConfig(@config, credentials: @config['credentials']) @@ -273,25 +307,36 @@ def create :name => MU::Cloud::Google.nameStr(@mu_name), :can_ip_forward => !@config['src_dst_check'], :description => @deploy.deploy_id, - :service_accounts => [service_acct], + :service_accounts => [@service_acct], :network_interfaces => interfaces, :machine_type => "zones/"+@config['availability_zone']+"/machineTypes/"+@config['size'], - :metadata => { - :items => [ - { - :key => "ssh-keys", - :value => @config['ssh_user']+":"+@deploy.ssh_public_key - }, - { - :key => "startup-script", - :value => @userdata - } - ] - }, :tags => MU::Cloud::Google.compute(:Tags).new(items: [MU::Cloud::Google.nameStr(@mu_name)]) } desc[:disks] = disks if disks.size > 0 + metadata = {} + if @config['metadata'] + metadata = Hash[@config['metadata'].map { |m| + [m["key"], m["value"]] + }] + end + metadata["startup-script"] = @userdata if @userdata and !@userdata.empty? + + deploykey = @config['ssh_user']+":"+@deploy.ssh_public_key + if metadata["ssh-keys"] + metadata["ssh-keys"] += "\n"+deploykey + else + metadata["ssh-keys"] = deploykey + end + desc[:metadata] = MU::Cloud::Google.compute(:Metadata).new( + :items => metadata.keys.map { |k| + MU::Cloud::Google.compute(:Metadata)::Item.new( + key: k, + value: metadata[k] + ) + } + ) + # Tags in GCP means something other than what we think of; # labels are the thing you think you mean desc[:labels] = {} @@ -302,10 +347,16 @@ def create } desc[:labels]["name"] = @mu_name.downcase + if @config['network_tags'] and @config['network_tags'].size > 0 + desc[:tags] = U::Cloud::Google.compute(:Tags).new( + items: @config['network_tags'] + ) + end instanceobj = MU::Cloud::Google.compute(:Instance).new(desc) - MU.log "Creating instance #{@mu_name}" + MU.log "Creating instance #{@mu_name} in #{@project_id} #{@config['availability_zone']}", details: instanceobj + begin instance = MU::Cloud::Google.compute(credentials: @config['credentials']).insert_instance( @project_id, @@ -351,7 +402,7 @@ def create parent_thread_id = Thread.current.object_id Thread.new { MU.dupGlobals(parent_thread_id) - MU::Cloud::Google::Server.cleanup(noop: false, ignoremaster: false, flags: { "skipsnapshots" => true } ) + MU::Cloud::Google::Server.cleanup(noop: false, ignoremaster: false, flags: { "skipsnapshots" => true }, region: @config['region'] ) } end end @@ -369,8 +420,10 @@ def self.genericNAT return { "cloud" => "Google", "size" => "g1-small", - "run_list" => [ "mu-utility::nat" ], + "run_list" => [ "mu-nat" ], + "groomer" => "Ansible", "platform" => "centos7", + "src_dst_check" => false, "ssh_user" => "centos", "associate_public_ip" => true, "static_ip" => { "assign_ip" => true }, @@ -467,7 +520,7 @@ def postBoot(instance_id = nil) return false if !MU::MommaCat.lock(@cloud_id+"-orchestrate", true) return false if !MU::MommaCat.lock(@cloud_id+"-groom", true) -# MU::MommaCat.createStandardTags(@cloud_id, region: @config['region']) +# MU::Cloud::AWS.createStandardTags(@cloud_id, region: @config['region']) # MU::MommaCat.createTag(@cloud_id, "Name", node, region: @config['region']) # # if @config['optional_tags'] @@ -586,78 +639,82 @@ def postBoot(instance_id = nil) end #postBoot # Locate an existing instance or instances and return an array containing matching AWS resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @param ip [String]: An IP address associated with the instance - # @param flags [Hash]: Optional flags # @return [Array>]: The cloud provider's complete descriptions of matching instances - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, ip: nil, flags: {}, credentials: nil) -# XXX put that 'ip' value into flags - instance = nil - flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) - if !region.nil? and MU::Cloud::Google.listRegions.include?(region) - regions = [region] + def self.find(**args) + args[:project] ||= args[:habitat] + args[:project] ||= MU::Cloud::Google.defaultProject(args[:credentials]) + if !args[:region].nil? and MU::Cloud::Google.listRegions.include?(args[:region]) + regions = [args[:region]] else regions = MU::Cloud::Google.listRegions end - found_instances = {} + found = {} search_semaphore = Mutex.new search_threads = [] # If we got an instance id, go get it - if !cloud_id.nil? and !cloud_id.empty? - parent_thread_id = Thread.current.object_id - regions.each { |region| - search_threads << Thread.new { - Thread.abort_on_exception = false - MU.dupGlobals(parent_thread_id) - MU.log "Hunting for instance with cloud id '#{cloud_id}' in #{region}", MU::DEBUG - MU::Cloud::Google.listAZs(region).each { |az| - resp = nil - begin - resp = MU::Cloud::Google.compute(credentials: credentials).get_instance( - flags["project"], + parent_thread_id = Thread.current.object_id + regions.each { |r| + search_threads << Thread.new(r) { |region| + Thread.abort_on_exception = false + MU.dupGlobals(parent_thread_id) + MU.log "Hunting for instance with cloud id '#{args[:cloud_id]}' in #{region}", MU::DEBUG + MU::Cloud::Google.listAZs(region).each { |az| + begin + if !args[:cloud_id].nil? and !args[:cloud_id].empty? + resp = MU::Cloud::Google.compute(credentials: args[:credentials]).get_instance( + args[:project], az, - cloud_id + args[:cloud_id] + ) + search_semaphore.synchronize { + found[args[:cloud_id]] = resp if !resp.nil? + } + else + resp = MU::Cloud::Google.compute(credentials: args[:credentials]).list_instances( + args[:project], + az ) - rescue ::Google::Apis::ClientError => e - raise e if !e.message.match(/^notFound: /) + if resp and resp.items + resp.items.each { |instance| + search_semaphore.synchronize { + found[instance.name] = instance + } + } + end end - found_instances[cloud_id] = resp if !resp.nil? - } + rescue ::OpenSSL::SSL::SSLError => e + MU.log "Got #{e.message} looking for instance #{args[:cloud_id]} in project #{args[:project]} (#{az}). Usually this means we've tried to query a non-functional region.", MU::DEBUG + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/^(?:notFound|forbidden): /) + end } } - done_threads = [] - begin - search_threads.each { |t| - joined = t.join(2) - done_threads << joined if !joined.nil? - } - end while found_instances.size < 1 and done_threads.size != search_threads.size - end - - if found_instances.size > 0 - return found_instances - end - + } + done_threads = [] + begin + search_threads.reject! { |t| t.nil? } + search_threads.each { |t| + joined = t.join(2) + done_threads << joined if !joined.nil? + } + end while found.size < 1 and done_threads.size != search_threads.size # Ok, well, let's try looking it up by IP then - if instance.nil? and !ip.nil? - MU.log "Hunting for instance by IP '#{ip}'", MU::DEBUG - end +# if instance.nil? and !args[:ip].nil? +# MU.log "Hunting for instance by IP '#{args[:ip]}'", MU::DEBUG +# end - if !instance.nil? - return {instance.name => instance} if !instance.nil? - end +# if !instance.nil? +# return {instance.name => instance} if !instance.nil? +# end # Fine, let's try it by tag. - if !tag_value.nil? - MU.log "Searching for instance by tag '#{tag_key}=#{tag_value}'", MU::DEBUG - end +# if !args[:tag_value].nil? +# MU.log "Searching for instance by tag '#{args[:tag_key]}=#{args[:tag_value]}'", MU::DEBUG +# end - return found_instances + return found end # Return a description of this resource appropriate for deployment @@ -722,7 +779,7 @@ def notify # Called automatically by {MU::Deploy#createResources} def groom - @project_id = MU::Cloud::Google.projectLookup(@config['project_id'], @deploy).cloudobj.cloud_id + @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloud_id MU::MommaCat.lock(@cloud_id+"-groom") @@ -799,12 +856,12 @@ def groom instance_id: @cloud_id, region: @config['region'], storage: @config['storage'], - family: ("mu-"+@config['platform']+"-"+MU.environment).downcase, project: @project_id, exclude_storage: img_cfg['image_exclude_storage'], make_public: img_cfg['public'], tags: @config['tags'], zone: @config['availability_zone'], + family: @config['family'], credentials: @config['credentials'] ) @deploy.notify("images", @config['name'], {"image_id" => image_id}) @@ -833,7 +890,7 @@ def groom # @param region [String]: The cloud provider region # @param tags [Array]: Extra/override tags to apply to the image. # @return [String]: The cloud provider identifier of the new machine image. - def self.createImage(name: nil, instance_id: nil, storage: {}, exclude_storage: false, project: nil, make_public: false, tags: [], region: nil, family: "mu", zone: MU::Cloud::Google.listAZs.sample, credentials: nil) + def self.createImage(name: nil, instance_id: nil, storage: {}, exclude_storage: false, project: nil, make_public: false, tags: [], region: nil, family: nil, zone: MU::Cloud::Google.listAZs.sample, credentials: nil) project ||= MU::Cloud::Google.defaultProject(credentials) instance = MU::Cloud::Server.find(cloud_id: instance_id, region: region) if instance.nil? @@ -889,46 +946,21 @@ def self.createImage(name: nil, instance_id: nil, storage: {}, exclude_storage: end labels["name"] = instance_id.downcase - imageobj = MU::Cloud::Google.compute(:Image).new( - name: name, - source_disk: bootdisk, - description: "Mu image created from #{name}", - labels: labels, - family: family - ) + image_desc = { + :name => name, + :source_disk => bootdisk, + :description => "Mu image created from #{name}", + :labels => labels + } + image_desc[:family] = family if family newimage = MU::Cloud::Google.compute(credentials: @config['credentials']).insert_image( project, - imageobj + MU::Cloud::Google.compute(:Image).new(image_desc) ) newimage.name end -# def cloud_desc -# max_retries = 5 -# retries = 0 -# if !@cloud_id.nil? -# begin -# return MU::Cloud::Google.compute(credentials: @config['credentials']).get_instance( -# @project_id, -# @config['availability_zone'], -# @cloud_id -# ) -# rescue ::Google::Apis::ClientError => e -# if e.message.match(/^notFound: /) -# return nil -# else -# raise e -# end -# end -# end -# nil -# end - - def cloud_desc - MU::Cloud::Google::Server.find(cloud_id: @cloud_id, credentials: @config['credentials']).values.first - end - # Return the IP address that we, the Mu server, should be using to access # this host via the network. Note that this does not factor in SSH # bastion hosts that may be in the path, see getSSHConfig if that's what @@ -972,7 +1004,8 @@ def getWindowsAdminPassword # @param dev [String]: Device name to use when attaching to instance # @param size [String]: Size (in gb) of the new volume # @param type [String]: Cloud storage type of the volume, if applicable - def addVolume(dev, size, type: "pd-standard") + # @param delete_on_termination [Boolean]: Value of delete_on_termination flag to set + def addVolume(dev, size, type: "pd-standard", delete_on_termination: false) devname = dev.gsub(/.*?\/([^\/]+)$/, '\1') resname = MU::Cloud::Google.nameStr(@mu_name+"-"+devname) MU.log "Creating disk #{resname}" @@ -1005,11 +1038,13 @@ def addVolume(dev, size, type: "pd-standard") end attachobj = MU::Cloud::Google.compute(:AttachedDisk).new( - auto_delete: true, device_name: devname, source: newdisk.self_link, - type: "PERSISTENT" + type: "PERSISTENT", + auto_delete: delete_on_termination ) + + MU.log "Attaching disk #{resname} to #{@cloud_id} at #{devname}" attachment = MU::Cloud::Google.compute(credentials: @config['credentials']).attach_disk( @project_id, @config['availability_zone'], @@ -1026,6 +1061,113 @@ def active? true end + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + def toKitten(rootparent: nil, billing: nil, habitats: nil) + bok = { + "cloud" => "Google", + "credentials" => @config['credentials'], + "cloud_id" => @cloud_id, + "project" => @project_id + } + if !cloud_desc + MU.log "toKitten failed to load a cloud_desc from #{@cloud_id}", MU::ERR, details: @config + return nil + end + bok['name'] = cloud_desc.name + + # XXX we can have multiple network interfaces, and often do; need + # language to account for this + iface = cloud_desc.network_interfaces.first + iface.network.match(/(?:^|\/)projects\/(.*?)\/.*?\/networks\/([^\/]+)(?:$|\/)/) + vpc_proj = Regexp.last_match[1] + vpc_id = Regexp.last_match[2] + + bok['vpc'] = MU::Config::Ref.get( + id: vpc_id, + cloud: "Google", + habitat: MU::Config::Ref.get( + id: vpc_proj, + cloud: "Google", + credentials: @credentials, + type: "habitats" + ), + credentials: @credentials, + type: "vpcs", + subnet_id: iface.subnetwork.sub(/.*?\/([^\/]+)$/, '\1') + ) + + cloud_desc.disks.each { |disk| + next if !disk.source + disk.source.match(/\/projects\/([^\/]+)\/zones\/([^\/]+)\/disks\/(.*)/) + proj = Regexp.last_match[1] + az = Regexp.last_match[2] + name = Regexp.last_match[3] + begin + disk_desc = MU::Cloud::Google.compute(credentials: @credentials).get_disk(proj, az, name) + if disk_desc.source_image and disk.boot + bok['image_id'] ||= disk_desc.source_image.sub(/^https:\/\/www\.googleapis\.com\/compute\/[^\/]+\//, '') + else + bok['storage'] ||= [] + storage_blob = { + "size" => disk_desc.size_gb, + "device" => "/dev/xvd"+(disk.index+97).chr.downcase + } + bok['storage'] << storage_blob + end + rescue ::Google::Apis::ClientError => e + MU.log "Failed to retrieve disk #{name} attached to server #{@cloud_id} in #{proj}/#{az}", MU::WARN, details: e.message + next + end + + } + + if cloud_desc.labels + bok['tags'] = cloud_desc.labels.keys.map { |k| { "key" => k, "value" => cloud_desc.labels[k] } } + end + if cloud_desc.tags and cloud_desc.tags.items and cloud_desc.tags.items.size > 0 + bok['network_tags'] = cloud_desc.tags.items + end + bok['src_dst_check'] = !cloud_desc.can_ip_forward + bok['size'] = cloud_desc.machine_type.sub(/.*?\/([^\/]+)$/, '\1') + bok['project'] = @project_id + if cloud_desc.service_accounts + bok['scopes'] = cloud_desc.service_accounts.map { |sa| sa.scopes }.flatten.uniq + end + if cloud_desc.metadata and cloud_desc.metadata.items + bok['metadata'] = cloud_desc.metadata.items.map { |m| MU.structToHash(m) } + end + + # Skip nodes that are just members of GKE clusters + if bok['name'].match(/^gke-.*?-[a-f0-9]+-[a-z0-9]+$/) and + bok['image_id'].match(/(:?^|\/)projects\/gke-node-images\//) + found_gke_tag = false + bok['network_tags'].each { |tag| + if tag.match(/^gke-/) + found_gke_tag = true + break + end + } + if found_gke_tag + MU.log "Server #{bok['name']} appears to belong to a ContainerCluster, skipping adoption", MU::DEBUG + return nil + end + end + + if bok['metadata'] + bok['metadata'].each { |item| + if item[:key] == "created-by" and item[:value].match(/\/instanceGroupManagers\//) + MU.log "Server #{bok['name']} appears to belong to a ServerPool, skipping adoption", MU::DEBUG, details: item[:value] + return nil + end + } + end + + + bok + end + # Does this resource type exist as a global (cloud-wide) artifact, or # is it localized to a region/zone? # @return [Boolean] @@ -1046,6 +1188,7 @@ def self.quality # @return [void] def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) + return if !MU::Cloud::Google::Habitat.isLive?(flags["project"], credentials) skipsnapshots = flags["skipsnapshots"] onlycloud = flags["onlycloud"] # XXX make damn sure MU.deploy_id is set @@ -1103,51 +1246,143 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent def self.schema(config) toplevel_required = [] schema = { - "image_id" => { + "roles" => MU::Cloud::Google::User.schema(config)[1]["roles"], + "create_image" => { + "properties" => { + "family" => { + "type" => "string", + "description" => "Add a GCP image +family+ string to the created image(s)" + } + } + }, + "availability_zone" => { "type" => "string", - "description" => "The Google Cloud Platform Image on which to base this instance. Will use the default appropriate for the platform, if not specified." + "description" => "Target this instance to a specific Availability Zone" + }, + "ssh_user" => { + "type" => "string", + "description" => "Account to use when connecting via ssh. Google Cloud images don't come with predefined remote access users, and some don't work with our usual default of +root+, so we recommend using some other (non-root) username.", + "default" => "muadmin" + }, + "network_tags" => { + "type" => "array", + "items" => { + "type" => "string", + "description" => "Add a network tag to this host, which can be used to selectively apply routes or firewall rules." + } + }, + "service_account" => MU::Config::Ref.schema( + type: "users", + desc: "An existing service account to use instead of the default one generated by Mu during the deployment process." + ), + "metadata" => { + "type" => "array", + "items" => { + "type" => "object", + "description" => "Custom key-value pairs to be added to the metadata of Google Cloud virtual machines", + "required" => ["key", "value"], + "properties" => { + "key" => { + "type" => "string" + }, + "value" => { + "type" => "string" + } + } + } }, "routes" => { "type" => "array", "items" => MU::Config::VPC.routeschema + }, + "scopes" => { + "type" => "array", + "items" => { + "type" => "string", + "description" => "API scopes to make available to this resource's service account." + }, + "default" => ["https://www.googleapis.com/auth/compute.readonly", "https://www.googleapis.com/auth/logging.write", "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/monitoring.write", "https://www.googleapis.com/auth/devstorage.read_only"] } } [toplevel_required, schema] end + @@instance_type_cache = {} + # Confirm that the given instance size is valid for the given region. # If someone accidentally specified an equivalent size from some other cloud provider, return something that makes sense. If nothing makes sense, return nil. # @param size [String]: Instance type to check # @param region [String]: Region to check against # @return [String,nil] - def self.validateInstanceType(size, region) - types = (MU::Cloud::Google.listInstanceTypes(region))[region] - if types and (size.nil? or !types.has_key?(size)) + def self.validateInstanceType(size, region, project: nil, credentials: nil) + size = size.dup.to_s + if @@instance_type_cache[project] and + @@instance_type_cache[project][region] and + @@instance_type_cache[project][region][size] + return @@instance_type_cache[project][region][size] + end + + if size.match(/\/?custom-(\d+)-(\d+)(?:-ext)?$/) + cpus = Regexp.last_match[1].to_i + mem = Regexp.last_match[2].to_i + ok = true + if cpus < 1 or cpus > 32 or (cpus % 2 != 0 and cpus != 1) + MU.log "Custom instance type #{size} illegal: CPU count must be 1 or an even number between 2 and 32", MU::ERR + ok = false + end + if (mem % 256) != 0 + MU.log "Custom instance type #{size} illegal: Memory must be a multiple of 256 (MB)", MU::ERR + ok = false + end + if ok + return "custom-#{cpus.to_s}-#{mem.to_s}" + else + return nil + end + end + + @@instance_type_cache[project] ||= {} + @@instance_type_cache[project][region] ||= {} + types = (MU::Cloud::Google.listInstanceTypes(region, project: project, credentials: credentials))[project][region] + realsize = size.dup + + if types and (realsize.nil? or !types.has_key?(realsize)) # See if it's a type we can approximate from one of the other clouds - atypes = (MU::Cloud::AWS.listInstanceTypes)[MU::Cloud::AWS.myRegion] foundmatch = false - if atypes and atypes.size > 0 and atypes.has_key?(size) - vcpu = atypes[size]["vcpu"] - mem = atypes[size]["memory"] - ecu = atypes[size]["ecu"] - types.keys.sort.reverse.each { |type| - features = types[type] - next if ecu == "Variable" and ecu != features["ecu"] - next if features["vcpu"] != vcpu - if (features["memory"] - mem.to_f).abs < 0.10*mem - foundmatch = true - MU.log "You specified an Amazon instance type '#{size}.' Approximating with Google Compute type '#{type}.'", MU::WARN - size = type - break - end - } - end + MU::Cloud.availableClouds.each { |cloud| + next if cloud == "Google" + cloudbase = Object.const_get("MU").const_get("Cloud").const_get(cloud) + foreign_types = (cloudbase.listInstanceTypes).values.first + if foreign_types.size == 1 + foreign_types = foreign_types.values.first + end + if foreign_types and foreign_types.size > 0 and foreign_types.has_key?(size) + vcpu = foreign_types[size]["vcpu"] + mem = foreign_types[size]["memory"] + ecu = foreign_types[size]["ecu"] + types.keys.sort.reverse.each { |type| + features = types[type] + next if ecu == "Variable" and ecu != features["ecu"] + next if features["vcpu"] != vcpu + if (features["memory"] - mem.to_f).abs < 0.10*mem + foundmatch = true + MU.log "You specified #{cloud} instance type '#{realsize}.' Approximating with Google Compute type '#{type}.'", MU::WARN + realsize = type + break + end + } + end + break if foundmatch + } + if !foundmatch - MU.log "Invalid size '#{size}' for Google Compute instance in #{region}. Supported types:", MU::ERR, details: types.keys.sort.join(", ") + MU.log "Invalid size '#{realsize}' for Google Compute instance in #{region} (checked project #{project}). Supported types:", MU::ERR, details: types.keys.sort.join(", ") + @@instance_type_cache[project][region][size] = nil return nil end end - size + @@instance_type_cache[project][region][size] = realsize + @@instance_type_cache[project][region][size] end @@ -1158,17 +1393,58 @@ def self.validateInstanceType(size, region) def self.validateConfig(server, configurator) ok = true - server['size'] = validateInstanceType(server["size"], server["region"]) - ok = false if server['size'].nil? + server['project'] ||= MU::Cloud::Google.defaultProject(server['credentials']) + + size = validateInstanceType(server["size"], server["region"], project: server['project'], credentials: server['credentials']) + + if size.nil? + MU.log "Failed to verify instance size #{server["size"]} for Server #{server['name']}", MU::WARN + else + server["size"] = size + end # If we're not targeting an availability zone, pick one randomly if !server['availability_zone'] server['availability_zone'] = MU::Cloud::Google.listAZs(server['region']).sample end + if server['service_account'] + server['service_account']['cloud'] = "Google" + server['service_account']['habitat'] ||= server['project'] + found = MU::Config::Ref.get(server['service_account']) + if found.id and !found.kitten + MU.log "GKE server #{server['name']} failed to locate service account #{server['service_account']} in project #{server['project']}", MU::ERR + ok = false + end + else + user = { + "name" => server['name'], + "cloud" => "Google", + "project" => server["project"], + "credentials" => server["credentials"], + "type" => "service" + } + if server['roles'] + user['roles'] = server['roles'].dup + end + configurator.insertKitten(user, "users", true) + server['dependencies'] ||= [] + server['service_account'] = MU::Config::Ref.get( + type: "users", + cloud: "Google", + name: server["name"], + project: server["project"], + credentials: server["credentials"] + ) + server['dependencies'] << { + "type" => "user", + "name" => server["name"] + } + end + subnets = nil if !server['vpc'] - vpcs = MU::Cloud::Google::VPC.find + vpcs = MU::Cloud::Google::VPC.find(credentials: server['credentials']) if vpcs["default"] server["vpc"] ||= {} server["vpc"]["vpc_id"] = vpcs["default"].self_link @@ -1199,9 +1475,14 @@ def self.validateConfig(server, configurator) end end + if server['vpc'] + server['vpc']['project'] ||= server['project'] + end + if server['image_id'].nil? - if MU::Config.google_images.has_key?(server['platform']) - server['image_id'] = configurator.getTail("server"+server['name']+"Image", value: MU::Config.google_images[server['platform']], prettyname: "server"+server['name']+"Image", cloudtype: "Google::::Apis::ComputeBeta::Image") + img_id = MU::Cloud.getStockImage("Google", platform: server['platform']) + if img_id + server['image_id'] = configurator.getTail("server"+server['name']+"Image", value: img_id, prettyname: "server"+server['name']+"Image", cloudtype: "Google::Apis::ComputeV1::Image") else MU.log "No image specified for #{server['name']} and no default available for platform #{server['platform']}", MU::ERR, details: server ok = false @@ -1212,7 +1493,6 @@ def self.validateConfig(server, configurator) begin real_image = MU::Cloud::Google::Server.fetchImage(server['image_id'].to_s, credentials: server['credentials']) rescue ::Google::Apis::ClientError => e - MU.log e.inspect, MU::WARN end if real_image.nil? @@ -1224,31 +1504,38 @@ def self.validateConfig(server, configurator) img_project = Regexp.last_match[1] img_name = Regexp.last_match[2] begin + img = MU::Cloud::Google.compute(credentials: server['credentials']).get_image(img_project, img_name) snaps = MU::Cloud::Google.compute(credentials: server['credentials']).list_snapshots( img_project, filter: "name eq #{img_name}-.*" ) server['storage'] ||= [] used_devs = server['storage'].map { |disk| disk['device'].gsub(/.*?\//, "") } - snaps.items.each { |snap| - next if !snap.labels.is_a?(Hash) or !snap.labels["mu-device-name"] or snap.labels["mu-parent-image"] != img_name - devname = snap.labels["mu-device-name"] - - if used_devs.include?(devname) - MU.log "Device name #{devname} already declared in server #{server['name']} (snapshot #{snap.name} wants the name)", MU::ERR - ok = false - end - server['storage'] << { - "snapshot_id" => snap.self_link, - "size" => snap.disk_size_gb, - "delete_on_termination" => true, - "device" => devname + if snaps and snaps.items + snaps.items.each { |snap| + next if !snap.labels.is_a?(Hash) or !snap.labels["mu-device-name"] or snap.labels["mu-parent-image"] != img_name + devname = snap.labels["mu-device-name"] + + if used_devs.include?(devname) + MU.log "Device name #{devname} already declared in server #{server['name']} (snapshot #{snap.name} wants the name)", MU::ERR + ok = false + end + server['storage'] << { + "snapshot_id" => snap.self_link, + "size" => snap.disk_size_gb, + "delete_on_termination" => true, + "device" => devname + } + used_devs << devname } - used_devs << devname - } + if snaps.items.size > 0 +# MU.log img_name, MU::WARN, details: snaps.items + end + end rescue ::Google::Apis::ClientError => e # it's ok, sometimes we don't have permission to list snapshots # in other peoples' projects +# MU.log img_name, MU::WARN, details: img raise e if !e.message.match(/^forbidden: /) end end diff --git a/modules/mu/clouds/google/server_pool.rb b/modules/mu/clouds/google/server_pool.rb index 6c1174dcc..213c50636 100644 --- a/modules/mu/clouds/google/server_pool.rb +++ b/modules/mu/clouds/google/server_pool.rb @@ -18,38 +18,30 @@ class Google # A server pool as configured in {MU::Config::BasketofKittens::server_pools} class ServerPool < MU::Cloud::ServerPool - @deploy = nil - @project_id = nil - @config = nil - attr_reader :mu_name - attr_reader :cloud_id - attr_reader :config - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::server_pools} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - if !mu_name.nil? - @mu_name = mu_name - @config['project'] ||= MU::Cloud::Google.defaultProject(@config['credentials']) - if !@project_id - project = MU::Cloud::Google.projectLookup(@config['project'], @deploy, sibling_only: true, raise_on_fail: false) - @project_id = project.nil? ? @config['project'] : project.cloudobj.cloud_id - end - elsif @config['scrub_mu_isms'] - @mu_name = @config['name'] - else - @mu_name = @deploy.getResourceName(@config['name']) - end + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + @mu_name ||= @deploy.getResourceName(@config['name']) end # Called automatically by {MU::Deploy#createResources} def create - @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloudobj.cloud_id port_objs = [] + sa = MU::Config::Ref.get(@config['service_account']) + if !sa or !sa.kitten or !sa.kitten.cloud_desc + raise MuError, "Failed to get service account cloud id from #{@config['service_account'].to_s}" + end + @service_acct = MU::Cloud::Google.compute(:ServiceAccount).new( + email: sa.kitten.cloud_desc.email, + scopes: @config['scopes'] + ) + if !@config['scrub_mu_isms'] + MU::Cloud::Google.grantDeploySecretAccess(@service_acct.email, credentials: @config['credentials']) + end + + @config['named_ports'].each { |port_cfg| port_objs << MU::Cloud::Google.compute(:NamedPort).new( name: port_cfg['name'], @@ -77,20 +69,30 @@ def create az = MU::Cloud::Google.listAZs(@config['region']).sample end + metadata = { # :items? + "startup-script" => @userdata + } + if @config['metadata'] + desc[:metadata] = Hash[@config['metadata'].map { |m| + [m["key"], m["value"]] + }] + end + deploykey = @config['ssh_user']+":"+@deploy.ssh_public_key + if desc[:metadata]["ssh-keys"] + desc[:metadata]["ssh-keys"] += "\n"+deploykey + else + desc[:metadata]["ssh-keys"] = deploykey + end + instance_props = MU::Cloud::Google.compute(:InstanceProperties).new( can_ip_forward: !@config['src_dst_check'], description: @deploy.deploy_id, -# machine_type: "zones/"+az+"/machineTypes/"+size, machine_type: size, + service_accounts: [@service_acct], labels: labels, disks: MU::Cloud::Google::Server.diskConfig(@config, false, false, credentials: @config['credentials']), network_interfaces: MU::Cloud::Google::Server.interfaceConfig(@config, @vpc), - metadata: { - :items => [ - :key => "ssh-keys", - :value => @config['ssh_user']+":"+@deploy.ssh_public_key - ] - }, + metadata: metadata, tags: MU::Cloud::Google.compute(:Tags).new(items: [MU::Cloud::Google.nameStr(@mu_name)]) ) @@ -132,9 +134,9 @@ def create # TODO this thing supports based on CPU usage, LB usage, or an arbitrary Cloud # Monitoring metric. The default is "sustained 60%+ CPU usage". We should # support all that. -# http://www.rubydoc.info/github/google/google-api-ruby-client/Google/Apis/ComputeBeta/AutoscalingPolicyCpuUtilization -# http://www.rubydoc.info/github/google/google-api-ruby-client/Google/Apis/ComputeBeta/AutoscalingPolicyLoadBalancingUtilization -# http://www.rubydoc.info/github/google/google-api-ruby-client/Google/Apis/ComputeBeta/AutoscalingPolicyCustomMetricUtilization +# http://www.rubydoc.info/github/google/google-api-ruby-client/Google/Apis/ComputeV1/AutoscalingPolicyCpuUtilization +# http://www.rubydoc.info/github/google/google-api-ruby-client/Google/Apis/ComputeV1/AutoscalingPolicyLoadBalancingUtilization +# http://www.rubydoc.info/github/google/google-api-ruby-client/Google/Apis/ComputeV1/AutoscalingPolicyCustomMetricUtilization policy_obj = MU::Cloud::Google.compute(:AutoscalingPolicy).new( cooldown_period_sec: @config['default_cooldown'], max_num_replicas: @config['max_size'], @@ -166,16 +168,156 @@ def notify end # Locate an existing ServerPool or ServerPools and return an array containing matching Google resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @param flags [Hash]: Optional flags - # @return [Array>]: The cloud provider's complete descriptions of matching ServerPools - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, flags: {}, credentials: nil) - flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) - MU.log "XXX ServerPool.find not yet implemented", MU::WARN - return {} + # @return [Hash]: The cloud provider's complete descriptions of matching ServerPools + def self.find(**args) + args[:project] ||= args[:habitat] + args[:project] ||= MU::Cloud::Google.defaultProject(args[:credentials]) + + regions = if args[:region] + [args[:region]] + else + MU::Cloud::Google.listRegions + end + found = {} + + regions.each { |r| + begin + resp = MU::Cloud::Google.compute(credentials: args[:credentials]).list_region_instance_group_managers(args[:project], args[:region]) + if resp and resp.items + resp.items.each { |igm| + found[igm.name] = igm + } + end + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/forbidden: /) + end + + begin +# XXX can these guys have name collisions? test this + MU::Cloud::Google.listAZs(r).each { |az| + resp = MU::Cloud::Google.compute(credentials: args[:credentials]).list_instance_group_managers(args[:project], az) + if resp and resp.items + resp.items.each { |igm| + found[igm.name] = igm + } + end + } + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/forbidden: /) + end + } + + return found + end + + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + def toKitten(rootparent: nil, billing: nil, habitats: nil) + bok = { + "cloud" => "Google", + "credentials" => @credentials, + "cloud_id" => @cloud_id, + "region" => @config['region'], + "project" => @project_id, + } + bok['name'] = cloud_desc.name + + scalers = if cloud_desc.zone and cloud_desc.zone.match(/-[a-z]$/) + bok['availability_zone'] = cloud_desc.zone.sub(/.*?\/([^\/]+)$/, '\1') + MU::Cloud::Google.compute(credentials: @credentials).list_autoscalers(@project_id, bok['availability_zone']) + else + MU::Cloud::Google.compute(credentials: @credentials).list_region_autoscalers(@project_id, @config['region'], filter: "target eq #{cloud_desc.self_link}") + end + + if scalers and scalers.items and scalers.items.size > 0 + scaler = scalers.items.first +MU.log bok['name'], MU::WARN, details: scaler.autoscaling_policy +# scaler.cpu_utilization.utilization_target +# scaler.cool_down_period_sec + bok['min_size'] = scaler.autoscaling_policy.min_num_replicas + bok['max_size'] = scaler.autoscaling_policy.max_num_replicas + else + bok['min_size'] = bok['max_size'] = cloud_desc.target_size + end +if cloud_desc.auto_healing_policies and cloud_desc.auto_healing_policies.size > 0 +MU.log bok['name'], MU::WARN, details: cloud_desc.auto_healing_policies +end + + template = MU::Cloud::Google.compute(credentials: @credentials).get_instance_template(@project_id, cloud_desc.instance_template.sub(/.*?\/([^\/]+)$/, '\1')) + + iface = template.properties.network_interfaces.first + iface.network.match(/(?:^|\/)projects\/(.*?)\/.*?\/networks\/([^\/]+)(?:$|\/)/) + vpc_proj = Regexp.last_match[1] + vpc_id = Regexp.last_match[2] + + bok['vpc'] = MU::Config::Ref.get( + id: vpc_id, + cloud: "Google", + habitat: MU::Config::Ref.get( + id: vpc_proj, + cloud: "Google", + credentials: @credentials, + type: "habitats" + ), + credentials: @credentials, + type: "vpcs", + subnet_pref: "any" # "anywhere in this VPC" is what matters + ) + + bok['basis'] = { + "launch_config" => { + "name" => bok['name'] + } + } + + template.properties.disks.each { |disk| + if disk.initialize_params.source_image and disk.boot + bok['basis']['launch_config']['image_id'] ||= disk.initialize_params.source_image.sub(/^https:\/\/www\.googleapis\.com\/compute\/[^\/]+\//, '') + elsif disk.type != "SCRATCH" + bok['basis']['launch_config']['storage'] ||= [] + storage_blob = { + "size" => disk.initialize_params.disk_size_gb, + "device" => "/dev/xvd"+(disk.index+97).chr.downcase + } + bok['basis']['launch_config']['storage'] << storage_blob + else + MU.log "Need to sort out scratch disks", MU::WARN, details: disk + end + + } + + if template.properties.labels + bok['tags'] = template.properties.labels.keys.map { |k| { "key" => k, "value" => template.properties.labels[k] } } + end + if template.properties.tags and template.properties.tags.items and template.properties.tags.items.size > 0 + bok['network_tags'] = template.properties.tags.items + end + bok['src_dst_check'] = !template.properties.can_ip_forward + bok['basis']['launch_config']['size'] = template.properties.machine_type.sub(/.*?\/([^\/]+)$/, '\1') + bok['project'] = @project_id + if template.properties.service_accounts + bok['scopes'] = template.properties.service_accounts.map { |sa| sa.scopes }.flatten.uniq + end + if template.properties.metadata and template.properties.metadata.items + bok['metadata'] = template.properties.metadata.items.map { |m| MU.structToHash(m) } + end + + # Skip nodes that are just members of GKE clusters + if bok['name'].match(/^gke-.*?-[a-f0-9]+-[a-z0-9]+$/) and + bok['basis']['launch_config']['image_id'].match(/(:?^|\/)projects\/gke-node-images\//) + gke_ish = true + bok['network_tags'].each { |tag| + gke_ish = false if !tag.match(/^gke-/) + } + if gke_ish + MU.log "ServerPool #{bok['name']} appears to belong to a ContainerCluster, skipping adoption", MU::NOTICE + return nil + end + end +#MU.log bok['name'], MU::WARN, details: [cloud_desc, template] + + bok end # Cloud-specific configuration properties. @@ -184,6 +326,15 @@ def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: n def self.schema(config) toplevel_required = [] schema = { + "ssh_user" => MU::Cloud::Google::Server.schema(config)[1]["ssh_user"], + "metadata" => MU::Cloud::Google::Server.schema(config)[1]["metadata"], + "service_account" => MU::Cloud::Google::Server.schema(config)[1]["service_account"], + "scopes" => MU::Cloud::Google::Server.schema(config)[1]["scopes"], + "network_tags" => MU::Cloud::Google::Server.schema(config)[1]["network_tags"], + "availability_zone" => { + "type" => "string", + "description" => "Target a specific availability zone for this pool, which will create zonal instance managers and scalers instead of regional ones." + }, "named_ports" => { "type" => "array", "items" => { @@ -211,6 +362,38 @@ def self.schema(config) # @return [Boolean]: True if validation succeeded, False otherwise def self.validateConfig(pool, configurator) ok = true +start = Time.now + pool['project'] ||= MU::Cloud::Google.defaultProject(pool['credentials']) + if pool['service_account'] + pool['service_account']['cloud'] = "Google" + pool['service_account']['habitat'] ||= pool['project'] + found = MU::Config::Ref.get(pool['service_account']) + if found.id and !found.kitten + MU.log "GKE pool #{pool['name']} failed to locate service account #{pool['service_account']} in project #{pool['project']}", MU::ERR + ok = false + end + else + user = { + "name" => pool['name'], + "cloud" => "Google", + "project" => pool["project"], + "credentials" => pool["credentials"], + "type" => "service" + } + configurator.insertKitten(user, "users", true) + pool['dependencies'] ||= [] + pool['service_account'] = MU::Config::Ref.get( + type: "users", + cloud: "Google", + name: pool["name"], + project: pool["project"], + credentials: pool["credentials"] + ) + pool['dependencies'] << { + "type" => "user", + "name" => pool["name"] + } + end pool['named_ports'] ||= [] if !pool['named_ports'].include?({"name" => "ssh", "port" => 22}) @@ -224,8 +407,9 @@ def self.validateConfig(pool, configurator) ok = false if launch['size'].nil? if launch['image_id'].nil? - if MU::Config.google_images.has_key?(pool['platform']) - launch['image_id'] = configurator.getTail("server_pool"+pool['name']+"Image", value: MU::Config.google_images[pool['platform']], prettyname: "server_pool"+pool['name']+"Image", cloudtype: "Google::Apis::ComputeBeta::Image") + img_id = MU::Cloud.getStockImage("Google", platform: pool['platform']) + if img_id + launch['image_id'] = configurator.getTail("server_pool"+pool['name']+"Image", value: img_id, prettyname: "server_pool"+pool['name']+"Image", cloudtype: "Google::Apis::ComputeV1::Image") else MU.log "No image specified for #{pool['name']} and no default available for platform #{pool['platform']}", MU::ERR, details: launch ok = false @@ -270,6 +454,7 @@ def self.quality # @return [void] def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) + return if !MU::Cloud::Google::Habitat.isLive?(flags["project"], credentials) if !flags["global"] ["region_autoscaler", "region_instance_group_manager"].each { |type| diff --git a/modules/mu/clouds/google/user.rb b/modules/mu/clouds/google/user.rb index be687bc97..3c398c7f7 100644 --- a/modules/mu/clouds/google/user.rb +++ b/modules/mu/clouds/google/user.rb @@ -17,45 +17,160 @@ class Cloud class Google # A user as configured in {MU::Config::BasketofKittens::users} class User < MU::Cloud::User - @deploy = nil - @config = nil - attr_reader :mu_name - attr_reader :config - attr_reader :cloud_id - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::users} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @cloud_id ||= cloud_id - @mu_name ||= @deploy.getResourceName(@config["name"]) + + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like +@vpc+, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + + # If we're being reverse-engineered from a cloud descriptor, use that + # to determine what sort of account we are. + if args[:from_cloud_desc] + MU::Cloud::Google.admin_directory + MU::Cloud::Google.iam + if args[:from_cloud_desc].class == ::Google::Apis::AdminDirectoryV1::User + @config['type'] = "interactive" + elsif args[:from_cloud_desc].class == ::Google::Apis::IamV1::ServiceAccount + @config['type'] = "service" + @config['name'] = args[:from_cloud_desc].display_name + if @config['name'].nil? or @config['name'].empty? + @config['name'] = args[:from_cloud_desc].name.sub(/.*?\/([^\/@]+)(?:@[^\/]*)?$/, '\1') + end + @cloud_id = args[:from_cloud_desc].name + else + raise MuError, "Google::User got from_cloud_desc arg of class #{args[:from_cloud_desc].class.name}, but doesn't know what to do with it" + end + end + + @mu_name ||= if (@config['unique_name'] or @config['type'] == "service") and !@config['scrub_mu_isms'] + @deploy.getResourceName(@config["name"]) + else + @config['name'] + end + end # Called automatically by {MU::Deploy#createResources} def create - if @config['type'] == "interactive" - bind_human_user - else + if @config['type'] == "service" + acct_id = @config['scrub_mu_isms'] ? @config['name'] : @deploy.getResourceName(@config["name"], max_length: 30).downcase req_obj = MU::Cloud::Google.iam(:CreateServiceAccountRequest).new( - account_id: @deploy.getResourceName(@config["name"], max_length: 30).downcase, + account_id: acct_id, service_account: MU::Cloud::Google.iam(:ServiceAccount).new( - display_name: @mu_name + display_name: @mu_name, + description: @config['scrub_mu_isms'] ? nil : @deploy.deploy_id + ) + ) + if @config['use_if_exists'] + # XXX maybe just set @cloud_id to projects/#{@project_id}/serviceAccounts/#{@mu_name}@#{@project_id}.iam.gserviceaccount.com and see if cloud_desc returns something + found = MU::Cloud::Google::User.find(project: @project_id, cloud_id: @mu_name) + if found.size == 1 + @cloud_id = found.keys.first + MU.log "Service account #{@cloud_id} already existed, using it" + end + end + + if !@cloud_id + MU.log "Creating service account #{@mu_name}" + resp = MU::Cloud::Google.iam(credentials: @config['credentials']).create_service_account( + "projects/"+@config['project'], + req_obj ) + @cloud_id = resp.name + end + + # make sure we've been created before moving on + begin + cloud_desc + rescue ::Google::Apis::ClientError => e + if e.message.match(/notFound:/) + sleep 3 + retry + end + end + elsif @config['external'] + @cloud_id = @config['email'] + MU::Cloud::Google::Role.bindFromConfig("user", @cloud_id, @config['roles'], credentials: @config['credentials']) + else + if !@config['email'] + domains = MU::Cloud::Google.admin_directory(credentials: @credentials).list_domains(@customer) + @config['email'] = @mu_name.gsub(/@.*/, "")+"@"+domains.domains.first.domain_name + end + + username_obj = MU::Cloud::Google.admin_directory(:UserName).new( + given_name: (@config['given_name'] || @config['name']), + family_name: (@config['family_name'] || @deploy.deploy_id), + full_name: @mu_name ) - MU.log "Creating service account #{@mu_name}" - MU::Cloud::Google.iam(credentials: @config['credentials']).create_service_account( - "projects/"+@config['project'], - req_obj + + user_obj = MU::Cloud::Google.admin_directory(:User).new( + name: username_obj, + primary_email: @config['email'], + suspended: @config['suspend'], + is_admin: @config['admin'], + password: MU.generateWindowsPassword, + change_password_at_next_login: (@config.has_key?('force_password_change') ? @config['force_password_change'] : true) ) + + MU.log "Creating user #{@mu_name}", details: user_obj + resp = MU::Cloud::Google.admin_directory(credentials: @credentials).insert_user(user_obj) + @cloud_id = resp.primary_email + end end # Called automatically by {MU::Deploy#createResources} def groom - if @config['type'] == "interactive" - bind_human_user + if @config['external'] + MU::Cloud::Google::Role.bindFromConfig("user", @cloud_id, @config['roles'], credentials: @config['credentials']) + elsif @config['type'] == "interactive" + need_update = false + MU::Cloud::Google::Role.bindFromConfig("user", @cloud_id, @config['roles'], credentials: @config['credentials']) + + if @config['force_password_change'] and !cloud_desc.change_password_at_next_login + MU.log "Forcing #{@mu_name} to change their password at next login", MU::NOTICE + need_update = true + elsif @config.has_key?("force_password_change") and + !@config['force_password_change'] and + cloud_desc.change_password_at_next_login + MU.log "No longer forcing #{@mu_name} to change their password at next login", MU::NOTICE + need_update = true + end + if @config['admin'] != cloud_desc.is_admin + MU.log "Setting 'is_admin' flag to #{@config['admin'].to_s} for directory user #{@mu_name}", MU::NOTICE + MU::Cloud::Google.admin_directory(credentials: @credentials).make_user_admin(@cloud_id, MU::Cloud::Google.admin_directory(:UserMakeAdmin).new(status: @config['admin'])) + end + + if @config['suspend'] != cloud_desc.suspended + need_update = true + end + if cloud_desc.name.given_name != (@config['given_name'] || @config['name']) or + cloud_desc.name.family_name != (@config['family_name'] || @deploy.deploy_id) or + cloud_desc.primary_email != @config['email'] + need_update = true + end + + if need_update + username_obj = MU::Cloud::Google.admin_directory(:UserName).new( + given_name: (@config['given_name'] || @config['name']), + family_name: (@config['family_name'] || @deploy.deploy_id), + full_name: @mu_name + ) + user_obj = MU::Cloud::Google.admin_directory(:User).new( + name: username_obj, + primary_email: @config['email'], + suspended: @config['suspend'], + change_password_at_next_login: (@config.has_key?('force_password_change') ? @config['force_password_change'] : true) + ) + + MU.log "Updating directory user #{@mu_name}", MU::NOTICE, details: user_obj + + resp = MU::Cloud::Google.admin_directory(credentials: @credentials).update_user(@cloud_id, user_obj) + @cloud_id = resp.primary_email + end + else + MU::Cloud::Google::Role.bindFromConfig("serviceAccount", @cloud_id.gsub(/.*?\/([^\/]+)$/, '\1'), @config['roles'], credentials: @config['credentials']) if @config['create_api_key'] resp = MU::Cloud::Google.iam(credentials: @config['credentials']).list_project_service_account_keys( cloud_desc.name @@ -73,34 +188,32 @@ def groom end # Retrieve the cloud descriptor for this resource. + # @return [Google::Apis::Core::Hashable] def cloud_desc - if @config['type'] == "interactive" - return nil - else - resp = MU::Cloud::Google.iam(credentials: @config['credentials']).list_project_service_accounts( - "projects/"+@config["project"] - ) - - if resp and resp.accounts - resp.accounts.each { |sa| - if sa.display_name and sa.display_name == @mu_name - return sa - end - } + if @config['type'] == "interactive" or !@config['type'] + @config['type'] ||= "interactive" + if !@config['external'] + return MU::Cloud::Google.admin_directory(credentials: @config['credentials']).get_user(@cloud_id) + else + return nil end + else + @config['type'] ||= "service" + MU::Cloud::Google.iam(credentials: @config['credentials']).get_project_service_account(@cloud_id) end + end # Return the metadata for this user configuration # @return [Hash] def notify - description = MU.structToHash(cloud_desc) - if description - description.delete(:etag) - return description + description = if !@config['external'] + MU.structToHash(cloud_desc) + else + {} end - { - } + description.delete(:etag) + description end # Does this resource type exist as a global (cloud-wide) artifact, or @@ -113,7 +226,7 @@ def self.isGlobal? # Denote whether this resource implementation is experiment, ready for # testing, or ready for production use. def self.quality - MU::Cloud::ALPHA + MU::Cloud::RELEASE end # Remove all users associated with the currently loaded deployment. @@ -122,6 +235,33 @@ def self.quality # @param region [String]: The cloud provider region # @return [void] def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) + my_domains = MU::Cloud::Google.getDomains(credentials) + my_org = MU::Cloud::Google.getOrg(credentials) + + # We don't have a good way of tagging directory users, so we rely + # on the known parameter, which is pulled from deployment metadata + if flags['known'] and my_org + dir_users = MU::Cloud::Google.admin_directory(credentials: credentials).list_users(customer: MU::Cloud::Google.customerID(credentials)).users + if dir_users + dir_users.each { |user| + if flags['known'].include?(user.primary_email) + MU.log "Deleting user #{user.primary_email} from #{my_org.display_name}", details: user + if !noop + MU::Cloud::Google.admin_directory(credentials: credentials).delete_user(user.id) + end + end + } + + flags['known'].each { |user_email| + next if user_email.nil? + next if !user_email.match(/^[^\/]+@[^\/]+$/) + + MU::Cloud::Google::Role.removeBindings("user", user_email, credentials: credentials, noop: noop) + } + + end + end + flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) resp = MU::Cloud::Google.iam(credentials: credentials).list_project_service_accounts( "projects/"+flags["project"] @@ -129,7 +269,8 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent if resp and resp.accounts and MU.deploy_id resp.accounts.each { |sa| - if sa.display_name and sa.display_name.match(/^#{Regexp.quote(MU.deploy_id)}-/i) + if (sa.description and sa.description == MU.deploy_id) or + (sa.display_name and sa.display_name.match(/^#{Regexp.quote(MU.deploy_id)}-/i)) begin MU.log "Deleting service account #{sa.name}", details: sa if !noop @@ -143,30 +284,167 @@ def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credent end end - # Locate an existing user. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region. - # @param flags [Hash]: Optional flags - # @return [OpenStruct]: The cloud provider's complete descriptions of matching user group. - def self.find(cloud_id: nil, region: MU.curRegion, credentials: nil, flags: {}, tag_key: nil, tag_value: nil) - flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) - found = nil - resp = MU::Cloud::Google.iam(credentials: credentials).list_project_service_accounts( - "projects/"+flags["project"] - ) + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) + cred_cfg = MU::Cloud::Google.credConfig(args[:credentials]) + args[:project] ||= args[:habitat] - if resp and resp.accounts - resp.accounts.each { |sa| - if sa.display_name and sa.display_name == cloud_id - found ||= {} - found[cloud_id] = sa + found = {} + + if args[:cloud_id] and args[:flags] and + args[:flags]["skip_provider_owned"] and + MU::Cloud::Google::User.cannedServiceAcctName?(args[:cloud_id]) + return found + end + + # If the project id is embedded in the cloud_id, honor it + if args[:cloud_id] + if args[:cloud_id].match(/projects\/(.+?)\//) + args[:project] = Regexp.last_match[1] + elsif args[:cloud_id].match(/@([^\.]+)\.iam\.gserviceaccount\.com$/) + args[:project] = Regexp.last_match[1] + end + end + + if args[:project] + # project-local service accounts + resp = begin + MU::Cloud::Google.iam(credentials: args[:credentials]).list_project_service_accounts( + "projects/"+args[:project] + ) + rescue ::Google::Apis::ClientError => e + MU.log "Do not have permissions to retrieve service accounts for project #{args[:project]}", MU::WARN + end + + if resp and resp.accounts + resp.accounts.each { |sa| + if args[:flags] and args[:flags]["skip_provider_owned"] and + MU::Cloud::Google::User.cannedServiceAcctName?(sa.name) + next + end + if !args[:cloud_id] or (sa.display_name and sa.display_name == args[:cloud_id]) or (sa.name and sa.name == args[:cloud_id]) or (sa.email and sa.email == args[:cloud_id]) + found[sa.name] = sa + end + } + end + else + if cred_cfg['masquerade_as'] + resp = MU::Cloud::Google.admin_directory(credentials: args[:credentials]).list_users(customer: MU::Cloud::Google.customerID(args[:credentials]), show_deleted: false) + if resp and resp.users + resp.users.each { |u| + found[u.primary_email] = u + } end - } + end end found end + # Try to determine whether the given string looks like a pre-configured + # GCP service account, as distinct from one we might create or manage + def self.cannedServiceAcctName?(name) + return false if !name + name.match(/\b\d+\-compute@developer\.gserviceaccount\.com$/) or + name.match(/\bproject-\d+@storage-transfer-service\.iam\.gserviceaccount\.com$/) or + name.match(/\b\d+@cloudbuild\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@containerregistry\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@container-analysis\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@gcp-sa-bigquerydatatransfer\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@gcp-sa-cloudasset\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@gcp-sa-cloudiot\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@gcp-sa-cloudscheduler\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@compute-system\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@container-engine-robot\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@gcp-admin-robot\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@gcp-sa-containerscanning\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@dataflow-service-producer-prod\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@dataproc-accounts\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@endpoints-portal\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@cloud-filer\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@cloud-redis\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@firebase-rules\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@cloud-tpu\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@gcp-sa-vpcaccess\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@gcp-sa-websecurityscanner\.iam\.gserviceaccount\.com$/) or + name.match(/\bservice-\d+@sourcerepo-service-accounts\.iam\.gserviceaccount\.com$/) or + name.match(/\bp\d+\-\d+@gcp-sa-logging\.iam\.gserviceaccount\.com$/) + end + + # We can either refer to a service account, which is scoped to a project + # (a +Habitat+ in Mu parlance), or a "real" user, which comes from + # an external directory like GMail, GSuite, or Cloud Identity. + def self.canLiveIn + [:Habitat, nil] + end + + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + def toKitten(rootparent: nil, billing: nil, habitats: nil) + if MU::Cloud::Google::User.cannedServiceAcctName?(@cloud_id) + return nil + end + + bok = { + "cloud" => "Google", + "credentials" => @config['credentials'] + } + + if cloud_desc.nil? + MU.log @config['name']+" couldn't fetch its cloud descriptor", MU::WARN, details: @cloud_id + return nil + end + + user_roles = MU::Cloud::Google::Role.getAllBindings(@config['credentials'])["by_entity"] + + if cloud_desc.nil? + MU.log "FAILED TO FIND CLOUD DESCRIPTOR FOR #{self}", MU::ERR, details: @config + return nil + end + + bok['name'] = @config['name'] + bok['cloud_id'] = @cloud_id + bok['type'] = @config['type'] + bok['type'] ||= "service" + if bok['type'] == "service" + bok['project'] = @project_id + keys = MU::Cloud::Google.iam(credentials: @config['credentials']).list_project_service_account_keys(@cloud_id) + + if keys and keys.keys and keys.keys.size > 0 + bok['create_api_key'] = true + end +# MU.log "service account #{@cloud_id}", MU::NOTICE, details: MU::Cloud::Google.iam(credentials: @config['credentials']).get_project_service_account_iam_policy(cloud_desc.name) + if user_roles["serviceAccount"] and + user_roles["serviceAccount"][bok['cloud_id']] and + user_roles["serviceAccount"][bok['cloud_id']].size > 0 + bok['roles'] = MU::Cloud::Google::Role.entityBindingsToSchema(user_roles["serviceAccount"][bok['cloud_id']]) + end + else + if user_roles["user"] and + user_roles["user"][bok['cloud_id']] and + user_roles["user"][bok['cloud_id']].size > 0 + bok['roles'] = MU::Cloud::Google::Role.entityBindingsToSchema(user_roles["user"][bok['cloud_id']], credentials: @config['credentials']) + end + bok['given_name'] = cloud_desc.name.given_name + bok['family_name'] = cloud_desc.name.family_name + bok['email'] = cloud_desc.primary_email + bok['suspend'] = cloud_desc.suspended + bok['admin'] = cloud_desc.is_admin + end + + bok['use_if_exists'] = true + + bok + end + # Cloud-specific configuration properties. # @param config [MU::Config]: The calling MU::Config object # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource @@ -175,24 +453,68 @@ def self.schema(config) schema = { "name" => { "type" => "string", - "description" => "This must be the email address of an existing Google user account (+foo@gmail.com+), or of a federated GSuite or Cloud Identity domain account from your organization." + "description" => "If the +type+ of this account is not +service+, this can include an optional @domain component (foo@example.com), which is equivalent to the +domain+ configuration option. The following rules apply to +directory+ (non-service) accounts only: + +If the domain portion is not specified, and we manage exactly one GSuite or Cloud Identity domain, we will attempt to create the user in that domain. + +If we do not manage any domains, and none are specified, we will assume @gmail.com for the domain and attempt to bind an existing external GMail user to roles under our jurisdiction. + +If the domain portion is specified, and our credentials can manage that domain via GSuite or Cloud Identity, we will attempt to create the user in that domain. + +If it is a domain we do not manage, we will attempt to bind an existing external user from that domain to roles under our jurisdiction. + +If we are binding (rather than creating) a user and no roles are specified, we will default to +roles/viewer+ at the organization scope. If our credentials do not manage an organization, we will grant this role in our default project. + +" + }, + "domain" => { + "type" => "string", + "description" => "If creating or binding an +interactive+ user, this is the domain of which the user should be a member. This can instead be embedded in the {name} field: +foo@example.com+." + }, + "given_name" => { + "type" => "string", + "description" => "Optionally set the +given_name+ field of a +directory+ account. Ignored for +service+ accounts." + }, + "first_name" => { + "type" => "string", + "description" => "Alias for +given_name+" + }, + "family_name" => { + "type" => "string", + "description" => "Optionally set the +family_name+ field of a +directory+ account. Ignored for +service+ accounts." + }, + "last_name" => { + "type" => "string", + "description" => "Alias for +family_name+" + }, + "email" => { + "type" => "string", + "description" => "Canonical email address for a +directory+ user. If not specified, will be set to +name@domain+." + }, + "external" => { + "type" => "boolean", + "description" => "Explicitly flag this user as originating from an external domain. This should always autodetect correctly." + }, + "admin" => { + "type" => "boolean", + "description" => "If the user is +interactive+ and resides in a domain we manage, set their +is_admin+ flag.", + "default" => false + }, + "suspend" => { + "type" => "boolean", + "description" => "If the user is +interactive+ and resides in a domain we manage, this can be used to lock their account.", + "default" => false }, "type" => { "type" => "string", - "description" => "'interactive' will attempt to bind an existing user; 'service' will create a service account and generate API keys" + "description" => "'interactive' will either attempt to bind an existing user to a role under our jurisdiction, or create a new directory user, depending on the domain of the user specified and whether we manage any directories; 'service' will create a service account and generate API keys.", + "enum" => ["interactive", "service"], + "default" => "interactive" }, "roles" => { "type" => "array", "description" => "One or more Google IAM roles to associate with this user.", - "default" => ["roles/viewer"], - "items" => { - "type" => "string", - "description" => "One or more Google IAM roles to associate with this user. Google Cloud human user accounts (as distinct from service accounts) are not created directly; pre-existing Google accounts are associated with a project by being bound to one or more roles in that project. If no roles are specified, we default to +roles/viewer+, which permits read-only access project-wide." - } - }, - "project" => { - "type" => "string", - "description" => "The project into which to deploy resources" + "items" => MU::Cloud::Google::Role.ref_schema } } [toplevel_required, schema] @@ -205,75 +527,107 @@ def self.schema(config) def self.validateConfig(user, configurator) ok = true - # admin_directory only works in a GSuite environment - if !user['name'].match(/@/i) and MU::Cloud::Google.credConfig(user['credentials'])['masquerade_as'] - # XXX flesh this check out, need to test with a GSuite site - pp MU::Cloud::Google.admin_directory(credentials: user['credentials']).get_user(user['name']) + my_domains = MU::Cloud::Google.getDomains(user['credentials']) + my_org = MU::Cloud::Google.getOrg(user['credentials']) + + # Deal with these name alias fields, here for the convenience of your + # easily confused english-centric type of person + user['given_name'] ||= user['first_name'] + user['family_name'] ||= user['last_name'] + user.delete("first_name") + user.delete("last_name") + + if user['name'].match(/@(.*+)$/) + domain = Regexp.last_match[1].downcase + if domain and user['domain'] and domain != user['domain'].downcase + MU.log "User #{user['name']} had a domain component, but the domain field was also specified (#{user['domain']}) and they don't match." + ok = false + end + user['domain'] = domain + if user['type'] == "service" + MU.log "Username #{user['name']} appears to be a directory or external username, cannot use with 'service'", MU::ERR + ok = false + else + user['type'] = "interactive" + if !my_domains or !my_domains.include?(domain) + user['external'] = true + + if !["gmail.com", "google.com"].include?(domain) + MU.log "#{user['name']} appears to be a member of a domain that our credentials (#{user['credentials']}) do not manage; attempts to grant access for this user may fail!", MU::WARN + end + + if !user['roles'] or user['roles'].empty? + user['roles'] = [ + { + "role" => { + "id" => "roles/viewer" + } + } + ] + MU.log "External Google user specified with no role binding, will grant 'viewer' in #{my_org ? "organization #{my_org.display_name}" : "project #{user['project']}"}", MU::WARN + end + else # this is actually targeting a domain we manage! yay! + end + end + elsif user['type'] != "service" + if !user['domain'] + if my_domains.size == 1 + user['domain'] = my_domains.first + elsif my_domains.size > 1 + MU.log "Google interactive User #{user['name']} did not specify a domain, and we have multiple defaults available. Must specify exactly one.", MU::ERR, details: my_domains + ok = false + else + user['domain'] = "gmail.com" + end + end end - if user['groups'] and user['groups'].size > 0 and - !MU::Cloud::Google.credConfig(user['credentials'])['masquerade_as'] - MU.log "Cannot change Google group memberships in non-GSuite environments.\nVisit https://groups.google.com to manage groups.", MU::ERR + if user['domain'] + user['email'] ||= user['name'].gsub(/@.*/, "")+"@"+user['domain'] + end + + if user['groups'] and user['groups'].size > 0 and my_org.nil? + MU.log "Cannot change Google group memberships with credentials that do not manage GSuite or Cloud Identity.\nVisit https://groups.google.com to manage groups.", MU::ERR ok = false end + if user['type'] == "service" + user['project'] ||= MU::Cloud::Google.defaultProject(user['credentials']) + end + if user['type'] != "service" and user["create_api_key"] MU.log "Only service accounts can have API keys in Google Cloud", MU::ERR ok = false end - ok - end - - private - - def bind_human_user - bindings = [] - ext_policy = MU::Cloud::Google.resource_manager(credentials: @config['credentials']).get_project_iam_policy( - @config['project'] - ) + user['dependencies'] ||= [] + if user['roles'] + user['roles'].each { |r| + if r['role'] and r['role']['name'] and + (!r['role']['deploy_id'] and !r['role']['id']) + user['dependencies'] << { + "type" => "role", + "name" => r['role']['name'] + } + end - change_needed = false - @config['roles'].each { |role| - seen = false - ext_policy.bindings.each { |b| - if b.role == role - seen = true - if !b.members.include?("user:"+@config['name']) - change_needed = true - b.members << "user:"+@config['name'] + if !r["projects"] and !r["organizations"] and !r["folders"] + if my_org + r["organizations"] = [my_org.name] + else + r["projects"] = [ + "id" => user["project"] + ] end end } - if !seen - ext_policy.bindings << MU::Cloud::Google.resource_manager(:Binding).new( - role: role, - members: ["user:"+@config['name']] - ) - change_needed = true - end - } - - if change_needed - req_obj = MU::Cloud::Google.resource_manager(:SetIamPolicyRequest).new( - policy: ext_policy - ) - MU.log "Adding #{@config['name']} to Google Cloud project #{@config['project']}", details: @config['roles'] - - begin - MU::Cloud::Google.resource_manager(credentials: @config['credentials']).set_project_iam_policy( - @config['project'], - req_obj - ) - rescue ::Google::Apis::ClientError => e - if e.message.match(/does not exist/i) and !MU::Cloud::Google.credConfig(@config['credentials'])['masquerade_as'] - raise MuError, "User #{@config['name']} does not exist, and we cannot create Google user in non-GSuite environments.\nVisit https://accounts.google.com to create new accounts." - end - raise e - end end + + ok end + private + end end end diff --git a/modules/mu/clouds/google/userdata/linux.erb b/modules/mu/clouds/google/userdata/linux.erb index 3749232e9..60d65fa22 100644 --- a/modules/mu/clouds/google/userdata/linux.erb +++ b/modules/mu/clouds/google/userdata/linux.erb @@ -1,5 +1,5 @@ #!/bin/sh -# Copyright:: Copyright (c) 2014 eGlobalTech, Inc., all rights reserved +# Copyright:: Copyright (c) 2017 eGlobalTech, Inc., all rights reserved # # Licensed under the BSD-3 license (the "License"); # you may not use this file except in compliance with the License. @@ -16,6 +16,13 @@ updates_run=0 need_reboot=0 instance_id="`curl http://metadata.google.internal/computeMetadata/v1/instance/name`" + +for f in /etc/rc.local /etc/rc.d/rc.local;do + if [ -f $f ];then + chmod 755 $f + fi +done + if [ -f /etc/debian_version ];then if ! grep '^/bin/sh /var/lib/cloud/instance/user-data.txt$' /etc/rc.local > /dev/null;then echo "/bin/sh /var/lib/cloud/instance/user-data.txt" >> /etc/rc.local @@ -65,9 +72,7 @@ elif [ -x /usr/bin/yum ];then sed -i 's/^Defaults.*requiretty$/Defaults !requiretty/' /etc/sudoers - if [ $version == 7 ];then - chmod 755 /etc/rc.d/rc.local - fi + chmod 755 /etc/rc.d/rc.local if [ ! -f /usr/bin/curl ] ;then /usr/bin/yum -y install curl;fi # Ugh, rando EPEL mirror if [ ! -f /etc/yum.repos.d/epel.repo ];then @@ -104,7 +109,7 @@ if [ ! -f /opt/chef/embedded/bin/ruby ];then set +e # We may run afoul of a synchronous bootstrap process doing the same thing. So # wait until we've managed to run successfully. - while ! sh chef-install.sh -v <%= MU.chefVersion %>;do + while ! sh chef-install.sh -v <%= $mu.chefVersion %>;do sleep 10 done touch /opt/mu_installed_chef @@ -117,7 +122,7 @@ if [ "$need_reboot" == "1" ];then fi <% end %> -gsutil cp gs://<%= MU.adminBucketName %>/<%= $mu.muID %>-secret . +gsutil cp gs://<%= $mu.adminBucketName %>/<%= $mu.muID %>-secret . echo ' require "openssl" @@ -127,11 +132,10 @@ print Base64.urlsafe_encode64(key.public_encrypt(File.read("<%= $mu.muID %>-secr ' > encrypt_deploy_secret.rb deploykey="<%= $mu.deployKey %>" -instance_id="`curl http://metadata.google.internal/computeMetadata/v1/instance/name`" # Make double-sure sshd is actually up service sshd restart -/usr/bin/curl -k --data mu_id="<%= $mu.muID %>" --data mu_resource_name="<%= $mu.resourceName %>" --data mu_resource_type="<%= $mu.resourceType %>" --data mu_instance_id="$instance_id" --data mu_bootstrap="1" --data mu_user="<%= $mu.muUser %>" --data mu_deploy_secret="`/opt/chef/embedded/bin/ruby encrypt_deploy_secret.rb`" https://<%= $mu.publicIP %>:2260/ +/usr/bin/curl -k --data mu_id="<%= $mu.muID %>" --data mu_resource_name="<%= $mu.resourceName %>" --data mu_resource_type="<%= $mu.resourceType %>" --data mu_instance_id="$instance_id" --data mu_bootstrap="1" --data mu_user="<%= $mu.muUser %>" --data mu_deploy_secret="`/opt/chef/embedded/bin/ruby encrypt_deploy_secret.rb`" https://<%= $mu.publicIP %>:<%= $mu.mommaCatPort %>/ /bin/rm -f <%= $mu.muID %>-secret mu_deploy_key.pub chef-install.sh encrypt_deploy_secret.rb touch /.mu_userdata_complete diff --git a/modules/mu/clouds/google/userdata/windows.erb b/modules/mu/clouds/google/userdata/windows.erb index 902e518d4..e6b3045ca 100644 --- a/modules/mu/clouds/google/userdata/windows.erb +++ b/modules/mu/clouds/google/userdata/windows.erb @@ -22,8 +22,8 @@ function log } function fetchSecret([string]$file){ - log "Fetching s3://<%= MU.adminBucketName %>/$file to $tmp/$file" - aws.cmd s3 cp s3://<%= MU.adminBucketName %>/$file $tmp/$file + log "Fetching s3://<%= $mu.adminBucketName %>/$file to $tmp/$file" + aws.cmd s3 cp s3://<%= $mu.adminBucketName %>/$file $tmp/$file } function importCert([string]$cert, [string]$store){ @@ -112,7 +112,7 @@ function removeChef($location){ $install_chef = $false $my_chef = (Get-ItemProperty $location | Where-Object {$_.DisplayName -like "chef client*"}).DisplayName if ($my_chef) { - if ($my_chef -match '<%= MU.chefVersion %>'.split('-')[0]) { + if ($my_chef -match '<%= $mu.chefVersion %>'.split('-')[0]) { $install_chef = $false } else{ log "Uninstalling Chef" @@ -142,13 +142,13 @@ If (!(Test-Path "c:\opscode\chef\embedded\bin\ruby.exe")){ } If ($install_chef){ - log "Installing Chef <%= MU.chefVersion %>" - If (!(Test-Path $env:Temp/chef-installer-<%= MU.chefVersion %>.msi)){ + log "Installing Chef <%= $mu.chefVersion %>" + If (!(Test-Path $env:Temp/chef-installer-<%= $mu.chefVersion %>.msi)){ log "Downloading Chef installer" - $WebClient.DownloadFile("https://www.chef.io/chef/download?p=windows&pv=2012&m=x86_64&v=<%= MU.chefVersion %>","$env:Temp/chef-installer-<%= MU.chefVersion %>.msi") + $WebClient.DownloadFile("https://www.chef.io/chef/download?p=windows&pv=2012&m=x86_64&v=<%= $mu.chefVersion %>","$env:Temp/chef-installer-<%= $mu.chefVersion %>.msi") } log "Running Chef installer" - (Start-Process -FilePath msiexec -ArgumentList "/i $env:Temp\chef-installer-<%= MU.chefVersion %>.msi ALLUSERS=1 /le $env:Temp\chef-client-install.log /qn" -Wait -Passthru).ExitCode + (Start-Process -FilePath msiexec -ArgumentList "/i $env:Temp\chef-installer-<%= $mu.chefVersion %>.msi ALLUSERS=1 /le $env:Temp\chef-client-install.log /qn" -Wait -Passthru).ExitCode Set-Content "c:/mu_installed_chef" "yup" } @@ -159,9 +159,9 @@ $deploy_secret = & "c:\opscode\chef\embedded\bin\ruby" -ropenssl -rbase64 -e "ke function callMomma([string]$act) { $params = @{mu_id='<%= $mu.muID %>';mu_resource_name='<%= $mu.resourceName %>';mu_resource_type='<%= $mu.resourceType %>';mu_instance_id="$awsid";mu_user='<%= $mu.muUser %>';mu_deploy_secret="$deploy_secret";$act="1"} - log "Calling Momma Cat at https://<%= $mu.publicIP %>:2260 with $act" + log "Calling Momma Cat at https://<%= $mu.publicIP %>:<%= $mu.mommaCatPort %> with $act" [System.Net.ServicePointManager]::ServerCertificateValidationCallback = {$true} # XXX - $resp = Invoke-WebRequest -Uri https://<%= $mu.publicIP %>:2260 -Method POST -Body $params + $resp = Invoke-WebRequest -Uri https://<%= $mu.publicIP %>:<%= $mu.mommaCatPort %> -Method POST -Body $params return $resp.Content } diff --git a/modules/mu/clouds/google/vpc.rb b/modules/mu/clouds/google/vpc.rb index 6fbd79d96..d4f397082 100644 --- a/modules/mu/clouds/google/vpc.rb +++ b/modules/mu/clouds/google/vpc.rb @@ -18,51 +18,24 @@ class Google # Creation of Virtual Private Clouds and associated artifacts (routes, subnets, etc). class VPC < MU::Cloud::VPC + attr_reader :cloud_desc_cache + attr_reader :routes - @deploy = nil - @config = nil - @project_id = nil - attr_reader :mu_name - attr_reader :cloud_id - attr_reader :url - attr_reader :config - - # @param mommacat [MU::MommaCat]: A {MU::Mommacat} object containing the deploy of which this resource is/will be a member. - # @param kitten_cfg [Hash]: The fully parsed and resolved {MU::Config} resource descriptor as defined in {MU::Config::BasketofKittens::vpcs} - def initialize(mommacat: nil, kitten_cfg: nil, mu_name: nil, cloud_id: nil) - @deploy = mommacat - @config = MU::Config.manxify(kitten_cfg) - @subnets = [] + # Initialize this cloud resource object. Calling +super+ will invoke the initializer defined under {MU::Cloud}, which should set the attribtues listed in {MU::Cloud::PUBLIC_ATTRS} as well as applicable dependency shortcuts, like @vpc, for us. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + def initialize(**args) + super + + @subnets ||= [] @subnetcachesemaphore = Mutex.new - if cloud_id and cloud_id.match(/^https:\/\//) - @url = cloud_id.clone - @cloud_id = cloud_id.to_s.gsub(/.*?\//, "") - elsif cloud_id and !cloud_id.empty? - @cloud_id = cloud_id.to_s - end - if !mu_name.nil? - @mu_name = mu_name - if @cloud_id.nil? or @cloud_id.empty? - @cloud_id = MU::Cloud::Google.nameStr(@mu_name) - end - @config['project'] ||= MU::Cloud::Google.defaultProject(@config['credentials']) - if !@project_id - project = MU::Cloud::Google.projectLookup(@config['project'], @deploy, sibling_only: true, raise_on_fail: false) - @project_id = project.nil? ? @config['project'] : project.cloudobj.cloud_id - end - loadSubnets - elsif @config['scrub_mu_isms'] - @mu_name = @config['name'] - else - @mu_name = @deploy.getResourceName(@config['name']) - end + loadSubnets if @cloud_id + @mu_name ||= @config['scrub_mu_isms'] ? @config['name'] : @deploy.getResourceName(@config['name']) end # Called automatically by {MU::Deploy#createResources} def create - @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloudobj.cloud_id networkobj = MU::Cloud::Google.compute(:Network).new( name: MU::Cloud::Google.nameStr(@mu_name), @@ -73,7 +46,7 @@ def create MU.log "Creating network #{@mu_name} (#{@config['ip_block']}) in project #{@project_id}", details: networkobj resp = MU::Cloud::Google.compute(credentials: @config['credentials']).insert_network(@project_id, networkobj) - @url = resp.self_link # XXX needs to go in notify + @url = resp.self_link @cloud_id = resp.name if @config['subnets'] @@ -82,8 +55,9 @@ def create @config['subnets'].each { |subnet| subnetthreads << Thread.new { MU.dupGlobals(parent_thread_id) - subnet_name = @config['name']+"-"+subnet['name'] - subnet_mu_name = MU::Cloud::Google.nameStr(@deploy.getResourceName(subnet_name)) + subnet_name = subnet['name'] + + subnet_mu_name = @config['scrub_mu_isms'] ? @cloud_id+subnet_name.downcase : MU::Cloud::Google.nameStr(@deploy.getResourceName(subnet_name, max_length: 61)) MU.log "Creating subnetwork #{subnet_mu_name} (#{subnet['ip_block']}) in project #{@project_id}", details: subnet subnetobj = MU::Cloud::Google.compute(:Subnetwork).new( name: subnet_mu_name, @@ -92,7 +66,14 @@ def create network: @url, region: subnet['availability_zone'] ) - resp = MU::Cloud::Google.compute(credentials: @config['credentials']).insert_subnetwork(@project_id, subnet['availability_zone'], subnetobj) + MU::Cloud::Google.compute(credentials: @config['credentials']).insert_subnetwork(@project_id, subnet['availability_zone'], subnetobj) + + # make sure the subnet we created exists, before moving on + subnetdesc = nil + begin + subnetdesc = MU::Cloud::Google.compute(credentials: @config['credentials']).get_subnetwork(@project_id, subnet['availability_zone'], subnet_mu_name) + sleep 1 + end while subnetdesc.nil? } } @@ -129,38 +110,44 @@ def trafficLogging(log_group_name: nil, resource_id: nil, resource_type: "VPC", def notify base = MU.structToHash(cloud_desc) base["cloud_id"] = @cloud_id - base["project_id"] = @project_id + base["project_id"] = habitat_id base.merge!(@config.to_h) + if @subnets + base["subnets"] = @subnets.map { |s| s.notify } + end base end # Describe this VPC from the cloud platform's perspective - # @return [Hash] + # @return [Google::Apis::Core::Hashable] def cloud_desc + if @cloud_desc_cache + return @cloud_desc_cache + end resp = MU::Cloud::Google.compute(credentials: @config['credentials']).get_network(@project_id, @cloud_id) - if @cloud_id.nil? or @cloud_id == "" + + if @cloud_id.nil? or @cloud_id == "" or resp.nil? MU.log "Couldn't describe #{self}, @cloud_id #{@cloud_id.nil? ? "undefined" : "empty" }", MU::ERR return nil end + @cloud_desc_cache = resp - resp = resp.to_h - @url ||= resp[:self_link] + # populate other parts and pieces of ourself + @url ||= resp.self_link routes = MU::Cloud::Google.compute(credentials: @config['credentials']).list_routes( @project_id, - filter: "network eq #{@cloud_id}" + filter: "network = \"#{@url}\"" ).items - resp[:routes] = routes.map { |r| r.to_h } if routes -# XXX subnets too + @routes = routes if routes and routes.size > 0 - resp + @cloud_desc_cache end # Called automatically by {MU::Deploy#createResources} def groom - @project_id = MU::Cloud::Google.projectLookup(@config['project'], @deploy).cloudobj.cloud_id - rtb = @config['route_tables'].first + rtb = @config['route_tables'].first # there's only ever one rtb['routes'].each { |route| # If we had a sibling server being spun up as a NAT, rig up the @@ -173,26 +160,32 @@ def groom if !@config['peers'].nil? count = 0 @config['peers'].each { |peer| - if peer['vpc']['vpc_name'] - peer_obj = @deploy.findLitterMate(name: peer['vpc']['vpc_name'], type: "vpcs") + if peer['vpc']['name'] + peer_obj = @deploy.findLitterMate(name: peer['vpc']['name'], type: "vpcs", habitat: peer['vpc']['project']) else tag_key, tag_value = peer['vpc']['tag'].split(/=/, 2) if !peer['vpc']['tag'].nil? - if peer['vpc']['deploy_id'].nil? and peer['vpc']['vpc_id'].nil? and tag_key.nil? + if peer['vpc']['deploy_id'].nil? and peer['vpc']['id'].nil? and tag_key.nil? peer['vpc']['deploy_id'] = @deploy.deploy_id end peer_obj = MU::MommaCat.findStray( - "Google", - "vpcs", - deploy_id: peer['vpc']['deploy_id'], - cloud_id: peer['vpc']['vpc_id'], - name: peer['vpc']['vpc_name'], - tag_key: tag_key, - tag_value: tag_value, - dummy_ok: true + "Google", + "vpcs", + deploy_id: peer['vpc']['deploy_id'], + cloud_id: peer['vpc']['id'], + name: peer['vpc']['name'], +# XXX project flag tho + tag_key: tag_key, + tag_value: tag_value, + dummy_ok: true ).first end +if peer_obj.nil? + MU.log "Failed VPC peer lookup on behalf of #{@cloud_id}", MU::WARN, details: peer + pr = peer['vpc']['project'] || @project_id + MU.log "all the VPCs I can see", MU::WARN, details: MU::Cloud::Google.compute(credentials: @config['credentials']).list_networks(pr) +end raise MuError, "No result looking for #{@mu_name}'s peer VPCs (#{peer['vpc']})" if peer_obj.nil? url = if peer_obj.cloudobj.url @@ -200,7 +193,6 @@ def groom elsif peer_obj.cloudobj.deploydata peer_obj.cloudobj.deploydata['self_link'] else - pp peer_obj.cloudobj.cloud_desc raise MuError, "Can't find the damn URL of my damn peer VPC #{peer['vpc']}" end cnxn_name = MU::Cloud::Google.nameStr(@mu_name+"-peer-"+count.to_s) @@ -227,41 +219,47 @@ def groom count += 1 } end + loadSubnets(use_cache: false) end - # Locate an existing VPC or VPCs and return an array containing matching Google cloud resource descriptors for those that match. - # @param cloud_id [String]: The cloud provider's identifier for this resource. - # @param region [String]: The cloud provider region - # @param tag_key [String]: A tag key to search. - # @param tag_value [String]: The value of the tag specified by tag_key to match when searching by tag. - # @return [Array>]: The cloud provider's complete descriptions of matching VPCs - def self.find(cloud_id: nil, region: MU.curRegion, tag_key: "Name", tag_value: nil, flags: {}, credentials: nil) - flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) -#MU.log "CALLED MU::Cloud::Google::VPC.find(#{cloud_id}, #{region}, #{tag_key}, #{tag_value}) with credentials #{credentials} from #{caller[0]}", MU::NOTICE, details: flags - + # Locate and return cloud provider descriptors of this resource type + # which match the provided parameters, or all visible resources if no + # filters are specified. At minimum, implementations of +find+ must + # honor +credentials+ and +cloud_id+ arguments. We may optionally + # support other search methods, such as +tag_key+ and +tag_value+, or + # cloud-specific arguments like +project+. See also {MU::MommaCat.findStray}. + # @param args [Hash]: Hash of named arguments passed via Ruby's double-splat + # @return [Hash]: The cloud provider's complete descriptions of matching resources + def self.find(**args) + args[:project] ||= args[:habitat] + args[:project] ||= MU::Cloud::Google.defaultProject(args[:credentials]) resp = {} - if cloud_id - vpc = MU::Cloud::Google.compute(credentials: credentials).get_network( - flags['project'], - cloud_id.to_s.sub(/^.*?\/([^\/]+)$/, '\1') + if args[:cloud_id] and args[:project] + begin + vpc = MU::Cloud::Google.compute(credentials: args[:credentials]).get_network( + args[:project], + args[:cloud_id].to_s.sub(/^.*?\/([^\/]+)$/, '\1') ) - resp[cloud_id] = vpc if !vpc.nil? + resp[args[:cloud_id]] = vpc if !vpc.nil? + rescue ::Google::Apis::ClientError => e + MU.log "Do not have permissions to retrieve VPC #{args[:cloud_id]} in project #{args[:project]}", MU::WARN, details: caller + end else # XXX other criteria - MU::Cloud::Google.compute(credentials: credentials).list_networks( - flags["project"] - ).items.each { |vpc| - resp[vpc.name] = vpc - } + vpcs = begin + MU::Cloud::Google.compute(credentials: args[:credentials]).list_networks( + args[:project] + ) + rescue ::Google::Apis::ClientError => e + raise e if !e.message.match(/^(?:notFound|forbidden): /) + end + + if vpcs and vpcs.items + vpcs.items.each { |v| + resp[v.name] = v + } + end end -#MU.log "THINGY", MU::WARN, details: resp - resp.each_pair { |cloud_id, vpc| - routes = MU::Cloud::Google.compute(credentials: credentials).list_routes( - flags["project"], - filter: "network eq #{vpc.self_link}" - ).items -# pp routes - } -#MU.log "RETURNING RESPONSE FROM VPC FIND (#{resp.class.name})", MU::WARN, details: resp + resp end @@ -279,72 +277,93 @@ def subnets # Describe subnets associated with this VPC. We'll compose identifying # information similar to what MU::Cloud.describe builds for first-class # resources. + # @param use_cache [Boolean]: If available, use saved deployment metadata to describe subnets, instead of querying the cloud API # @return [Array]: A list of cloud provider identifiers of subnets associated with this VPC. - def loadSubnets + def loadSubnets(use_cache: true) + @subnetcachesemaphore.synchronize { + return @subnets if use_cache and @subnets and @subnets.size > 0 + } network = cloud_desc + if network.nil? MU.log "Unabled to load cloud description in #{self}", MU::ERR return nil end found = [] - resp = nil - MU::Cloud::Google.listRegions(@config['us_only']).each { |r| - resp = MU::Cloud::Google.compute(credentials: @config['credentials']).list_subnetworks( + if @deploy and @deploy.deployment and + @deploy.deployment["vpcs"] and + @deploy.deployment["vpcs"][@config['name']] and + @deploy.deployment["vpcs"][@config['name']]["subnets"] and + @deploy.deployment["vpcs"][@config['name']]["subnets"].size > 0 + @deploy.deployment["vpcs"][@config['name']]["subnets"].each { |desc| + subnet = {} + subnet["ip_block"] = desc['ip_block'] + subnet["name"] = desc["name"] + subnet['mu_name'] = @config['scrub_mu_isms'] ? @cloud_id+subnet['name'].downcase : MU::Cloud::Google.nameStr(@deploy.getResourceName(subnet['name'], max_length: 61)) + subnet["cloud_id"] = desc['cloud_id'] + subnet["cloud_id"] ||= desc['self_link'].gsub(/.*?\/([^\/]+)$/, '\1') + subnet["cloud_id"] ||= subnet['mu_name'] + subnet['az'] = desc["az"] + subnet['az'] ||= desc["region"].gsub(/.*?\/([^\/]+)$/, '\1') + @subnets << MU::Cloud::Google::VPC::Subnet.new(self, subnet, precache_description: false) + } + else + resp = MU::Cloud::Google.compute(credentials: @config['credentials']).list_subnetwork_usable( @project_id, - r, - filter: "network eq #{network[:self_link]}" + filter: "network eq #{network.self_link}" ) - next if resp.nil? or resp.items.nil? resp.items.each { |subnet| found << subnet } - } - @subnetcachesemaphore.synchronize { - @subnets ||= [] - ext_ids = @subnets.each.collect { |s| s.cloud_id } - - # If we're a plain old Mu resource, load our config and deployment - # metadata. Like ya do. - if !@config.nil? and @config.has_key?("subnets") - @config['subnets'].each { |subnet| - subnet['mu_name'] = @mu_name+"-"+subnet['name'] if !subnet.has_key?("mu_name") - subnet['region'] = @config['region'] - found.each { |desc| - if desc.ip_cidr_range == subnet["ip_block"] - subnet["cloud_id"] = desc.name - subnet["url"] = desc.self_link - subnet['az'] = desc.region.gsub(/.*?\//, "") - break + @subnetcachesemaphore.synchronize { + @subnets ||= [] + ext_ids = @subnets.each.collect { |s| s.cloud_id } + # If we're a plain old Mu resource, load our config and deployment + # metadata. Like ya do. + if !@config.nil? and @config.has_key?("subnets") + @config['subnets'].each { |subnet| +# subnet['mu_name'] = @mu_name+"-"+subnet['name'] if !subnet.has_key?("mu_name") + subnet['mu_name'] ||= @config['scrub_mu_isms'] ? @cloud_id+subnet['name'].downcase : MU::Cloud::Google.nameStr(@deploy.getResourceName(subnet['name'], max_length: 61)) + subnet['region'] = @config['region'] + found.each { |desc| + if desc.ip_cidr_range == subnet["ip_block"] + desc.subnetwork.match(/\/projects\/[^\/]+\/regions\/([^\/]+)\/subnetworks\/(.+)$/) + subnet['az'] = Regexp.last_match[1] + subnet['name'] ||= Regexp.last_match[2] + subnet["cloud_id"] = subnet['mu_name'] + subnet["url"] = desc.subnetwork + break + end + } + + if !ext_ids.include?(subnet["cloud_id"]) + @subnets << MU::Cloud::Google::VPC::Subnet.new(self, subnet, precache_description: false) end } + # Of course we might be loading up a dummy subnet object from a + # foreign or non-Mu-created VPC and subnet. So make something up. + elsif !found.nil? + found.each { |desc| + subnet = {} + desc.subnetwork.match(/\/projects\/[^\/]+\/regions\/([^\/]+)\/subnetworks\/(.+)$/) + subnet['az'] = Regexp.last_match[1] + subnet['name'] = Regexp.last_match[2] + subnet["cloud_id"] = subnet['name'] + subnet["ip_block"] = desc.ip_cidr_range + subnet["url"] = desc.subnetwork + subnet['mu_name'] = @mu_name+"-"+subnet['name'] + if !ext_ids.include?(subnet["cloud_id"]) + @subnets << MU::Cloud::Google::VPC::Subnet.new(self, subnet, precache_description: false) + end + } + end + } + end - if !ext_ids.include?(subnet["cloud_id"]) - @subnets << MU::Cloud::Google::VPC::Subnet.new(self, subnet) - end - } - - # Of course we might be loading up a dummy subnet object from a - # foreign or non-Mu-created VPC and subnet. So make something up. - elsif !found.nil? - found.each { |desc| - subnet = {} - subnet["ip_block"] = desc.ip_cidr_range - subnet["name"] = subnet["ip_block"].gsub(/[\.\/]/, "_") - subnet['mu_name'] = @mu_name+"-"+subnet['name'] - subnet["cloud_id"] = desc.name - subnet['az'] = subnet['region'] = desc.region.gsub(/.*?\//, "") - if !ext_ids.include?(desc.name) - @subnets << MU::Cloud::Google::VPC::Subnet.new(self, subnet) - end - } - end - - } return @subnets - end # Given some search criteria try locating a NAT Gaateway in this VPC. @@ -363,7 +382,7 @@ def findNat(nat_cloud_id: nil, nat_filter_key: nil, nat_filter_value: nil, regio # @param nat_tag_value [String]: A cloud provider tag to help identify the resource, used in conjunction with tag_key. # @param nat_ip [String]: An IP address associated with the NAT instance. def findBastion(nat_name: nil, nat_cloud_id: nil, nat_tag_key: nil, nat_tag_value: nil, nat_ip: nil) - nat = nil + deploy_id = nil nat_name = nat_name.to_s if !nat_name.nil? and nat_name.class.to_s == "MU::Config::Tail" nat_ip = nat_ip.to_s if !nat_ip.nil? and nat_ip.class.to_s == "MU::Config::Tail" @@ -386,42 +405,52 @@ def findBastion(nat_name: nil, nat_cloud_id: nil, nat_tag_key: nil, nat_tag_valu dummy_ok: true, calling_deploy: @deploy ) -# XXX wat + return nil if found.nil? || found.empty? - if found.size > 1 + + if found.size == 1 + return found.first + elsif found.size > 1 found.each { |nat| + next if !nat.cloud_desc # Try some cloud-specific criteria - cloud_desc = nat.cloud_desc - if !nat_host_ip.nil? and -# XXX this is AWS code, is wrong here - (cloud_desc.private_ip_address == nat_host_ip or cloud_desc.public_ip_address == nat_host_ip) - return nat - elsif cloud_desc.vpc_id == @cloud_id - # XXX Strictly speaking we could have different NATs in different - # subnets, so this can be wrong in corner cases. Why you'd - # architect something that obnoxiously, I have no idea. - return nat - end + nat.cloud_desc.network_interfaces.each { |iface| + if !nat_ip.nil? + return nat if iface.network_ip == nat_ip + if iface.access_configs + iface.access_configs.each { |public_iface| + return if public_iface.nat_ip == nat_ip + } + end + end + if iface.network == @url + # XXX Strictly speaking we could have different NATs in + # different subnets, so this can be wrong in corner cases. + return nat + end + } } - elsif found.size == 1 - return found.first end + return nil end # Check for a subnet in this VPC matching one or more of the specified # criteria, and return it if found. - def getSubnet(cloud_id: nil, name: nil, tag_key: nil, tag_value: nil, ip_block: nil) - loadSubnets + def getSubnet(cloud_id: nil, name: nil, tag_key: nil, tag_value: nil, ip_block: nil, region: nil) if !cloud_id.nil? and cloud_id.match(/^https:\/\//) + cloud_id.match(/\/regions\/([^\/]+)\/subnetworks\/([^\/]+)$/) + region = Regexp.last_match[1] + cloud_id = Regexp.last_match[2] cloud_id.gsub!(/.*?\//, "") end - MU.log "getSubnet(cloud_id: #{cloud_id}, name: #{name}, tag_key: #{tag_key}, tag_value: #{tag_value}, ip_block: #{ip_block})", MU::DEBUG, details: caller[0] - - @subnets.each { |subnet| + MU.log "getSubnet(cloud_id: #{cloud_id}, name: #{name}, tag_key: #{tag_key}, tag_value: #{tag_value}, ip_block: #{ip_block}, region: #{region})", MU::DEBUG, details: caller[0] + subnets.each { |subnet| + next if region and subnet.az != region if !cloud_id.nil? and !subnet.cloud_id.nil? and subnet.cloud_id.to_s == cloud_id.to_s return subnet - elsif !name.nil? and !subnet.name.nil? and subnet.name.to_s == name.to_s + elsif !name.nil? and !subnet.name.nil? and + subnet.name.downcase.to_s == name.downcase.to_s return subnet end } @@ -507,24 +536,157 @@ def self.quality # @return [void] def self.cleanup(noop: false, ignoremaster: false, region: MU.curRegion, credentials: nil, flags: {}) flags["project"] ||= MU::Cloud::Google.defaultProject(credentials) + return if !MU::Cloud::Google::Habitat.isLive?(flags["project"], credentials) purge_subnets(noop, project: flags['project'], credentials: credentials) ["route", "network"].each { |type| # XXX tagged routes aren't showing up in list, and the networks that own them # fail to delete silently - MU::Cloud::Google.compute(credentials: credentials).delete( - type, - flags["project"], - nil, - noop - ) + retries = 0 + + begin + MU::Cloud::Google.compute(credentials: credentials).delete( + type, + flags["project"], + nil, + noop + ) + rescue MU::MuError, ::Google::Apis::ClientError => e + if retries < 5 + if type == "network" + MU.log e.message, MU::WARN + if e.message.match(/Failed to delete network (.+)/) + network_name = Regexp.last_match[1] + fwrules = MU::Cloud::Google::FirewallRule.find(project: flags['project'], credentials: credentials) + fwrules.reject! { |name, desc| + !desc.network.match(/.*?\/#{Regexp.quote(network_name)}$/) + } + fwrules.keys.each { |name| + MU.log "Attempting to delete firewall rule #{name} so that VPC #{network_name} can be removed", MU::NOTICE + MU::Cloud::Google.compute(credentials: credentials).delete_firewall(flags['project'], name) + } + end + end + sleep retries*3 + retries += 1 + retry + else + raise e + end + end } + + end + + # Reverse-map our cloud description into a runnable config hash. + # We assume that any values we have in +@config+ are placeholders, and + # calculate our own accordingly based on what's live in the cloud. + # XXX add flag to return the diff between @config and live cloud + def toKitten(rootparent: nil, billing: nil, habitats: nil) + return nil if cloud_desc.name == "default" # parent project builds these + bok = { + "cloud" => "Google", + "project" => @config['project'], + "credentials" => @config['credentials'] + } + MU::Cloud::Google.listRegions.size + + diff = {} + schema, valid = MU::Config.loadResourceSchema("VPC", cloud: "Google") + return [nil, nil] if !valid +# pp schema +# MU.log "++++++++++++++++++++++++++++++++" + + bok['name'] = cloud_desc.name.dup + bok['cloud_id'] = cloud_desc.name.dup + bok['create_standard_subnets'] = false + + if @subnets and @subnets.size > 0 + bok['subnets'] = [] + regions_seen = [] + names_seen = [] + @subnets.map { |x| x.cloud_desc }.each { |s| + subnet_name = s.name.dup + names_seen << s.name.dup + regions_seen << s.region + bok['subnets'] << { + "name" => subnet_name, + "ip_block" => s.ip_cidr_range + } + } + + # If all of the subnets are named 'default' and there's one per + # region, we're using GCP-generated subnets instead of explicitly + # declared ones. + if names_seen.uniq.size == 1 and names_seen.first == "default" and + regions_seen.uniq.size == regions_seen.size and + regions_seen.size >= (MU::Cloud::Google.listRegions.size * 0.8) + bok.delete("subnets") + bok['auto_create_subnetworks'] = true + end + end + + peer_names = [] + if cloud_desc.peerings and cloud_desc.peerings.size > 0 + bok['peers'] = [] + cloud_desc.peerings.each { |peer| + peer.network.match(/projects\/([^\/]+?)\/[^\/]+?\/networks\/([^\/]+)$/) + vpc_project = Regexp.last_match[1] + vpc_name = Regexp.last_match[2] + vpc_id = vpc_name.dup + # Make sure the peer is something we have permission to look at + peer_descs = MU::Cloud::Google::VPC.find(cloud_id: vpc_id, project: vpc_project) + if peer_descs.nil? or peer_descs.empty? + MU.log "VPC #{@cloud_id} peer #{vpc_id} #{vpc_project} is not accessible, will remove from peer list", MU::WARN + next + end +# XXX need to decide which of these parameters to use based on whether the peer is also in the mix of things being harvested, which is above this method's pay grade + bok['peers'] << { "vpc" => MU::Config::Ref.get( + id: vpc_id, + name: vpc_name, + cloud: "Google", + habitat: MU::Config::Ref.get( + id: vpc_project, + cloud: "Google", + credentials: @credentials, + type: "habitats" + ), + credentials: @config['credentials'], + type: "vpcs" + ) } + } + end + +# XXX need to grok VPN tunnels, priorities, and maybe preserve descriptions; make sure we know where next_hop_gateway and next_hop_ip come from + if @routes + routes = [] + @routes.each { |r| + next if r.next_hop_peering # these are auto-created + route = { + "destination_network" => r.dest_range + } + if r.next_hop_instance + route["nat_host_id"] = r.next_hop_instance + end + } + if routes.size > 0 + bok['route_tables'] = [ + { + "name" => "default", + "routes" => routes + } + ] + end + end + +# XXX validate that we've at least touched every required attribute (maybe upstream?) + bok end # Cloud-specific configuration properties. # @param config [MU::Config]: The calling MU::Config object # @return [Array]: List of required fields, and json-schema Hash of cloud-specific configuration parameters for this resource - def self.schema(config) + def self.schema(config = nil) toplevel_required = [] schema = { "regions" => { @@ -533,12 +695,79 @@ def self.schema(config) }, "project" => { "type" => "string", - "description" => "The project into which to deploy resources" + "description" => "The project into which to deploy resources. This is shorthand for a +habitat+ key with a +name+ or +id+ set. The config parser will attempt to correctly resolve this." + }, + "auto_create_subnetworks" => { + "type" => "boolean", + "default" => false, + "description" => "Sets the +auto_create_subnetworks+ flag, which causes Google to generate a set of generic subnets, one per region. This effectively overrides Mu's +create_standard_subnets+ and any explicitly defined +subnets+." } } [toplevel_required, schema] end + # If the VPC a config block was set to one that's been "split," try to + # figure out which of the new VPCs we really want to be in. For use by + # resource types that don't go in subnets, but do tie to VPCs. + # @param vpc_block [Hash] + # @param configurator [MU::Config] + # @return [Hash] + def self.pickVPC(vpc_block, my_config, my_type, configurator) + _shortclass, cfg_name, cfg_plural, _classname = MU::Cloud.getResourceNames(my_type) + return if vpc_block.nil? + vpc_block['name'] ||= vpc_block['vpc_name'] + return if !vpc_block['name'] + + vpcs = configurator.haveLitterMate?( + nil, + "vpcs", + has_multiple: true + ) + # drop all virtual vpcs that aren't real anymore + vpcs.reject! { |v| v['virtual_name'] == v['name'] } + # drop the ones that have nothing to do with us + vpcs.reject! { |v| v['virtual_name'] != vpc_block['name'] } + + return vpc_block if vpcs.size == 0 + + # see if one of this thing's siblings declared a subnet_pref we can + # use to guess which one we should marry ourselves to + configurator.kittens.each_pair { |type, siblings| + siblings.each { |sibling| + next if !sibling['dependencies'] + sibling['dependencies'].each { |dep| + if [cfg_name, cfg_plural].include?(dep['type']) and + dep['name'] == my_config['name'] + vpcs.each { |v| + if sibling['vpc']['name'] == v['name'] + vpc_block['name'] = v['name'] + return vpc_block + end + } + if sibling['vpc']['subnet_pref'] + vpcs.each { |v| + gateways = v['route_tables'].map { |rtb| + rtb['routes'].map { |r| r["gateway"] } + }.flatten.uniq + if ["public", "all_public"].include?(sibling['vpc']['subnet_pref']) and + gateways.include?("#INTERNET") + vpc_block['name'] = v['name'] + return vpc_block + elsif ["private", "all_private"].include?(sibling['vpc']['subnet_pref']) and + !gateways.include?("#INTERNET") + vpc_block['name'] = v['name'] + return vpc_block + end + } + + end + end + } + } + } + + vpc_block + end # Cloud-specific pre-processing of {MU::Config::BasketofKittens::vpcs}, bare and unvalidated. # @param vpc [Hash]: The resource to process and validate @@ -547,35 +776,10 @@ def self.schema(config) def self.validateConfig(vpc, configurator) ok = true + vpc['project'] ||= MU::Cloud::Google.defaultProject(vpc['credentials']) - if vpc['create_standard_subnets'] - # Manufacture some generic routes, if applicable. - if !vpc['route_tables'] or vpc['route_tables'].empty? - vpc['route_tables'] = [ - { - "name" => "internet", - "routes" => [ { "destination_network" => "0.0.0.0/0", "gateway" => "#INTERNET" } ] - }, - { - "name" => "private", - "routes" => [ { "destination_network" => "0.0.0.0/0", "gateway" => "#NAT" } ] - } - ] - end - else - # If create_standard_subnets is off, and no route_tables were - # declared at all, let's assume we want purely self-contained - # private VPC, and create a dummy route accordingly. - vpc['route_tables'] ||= [ - { - "name" => "private", - "routes" => [ - { - "destination_network" => "0.0.0.0/0" - } - ] - } - ] + if vpc["project"] and !vpc["habitat"] + vpc["habitat"] = MU::Cloud::Google.projectToRef(vpc["project"], config: configurator, credentials: vpc["credentials"]) end # Generate a set of subnets per route, if none are declared @@ -588,21 +792,42 @@ def self.validateConfig(vpc, configurator) vpc["subnets"] = [] vpc['route_tables'].each { |t| + is_public = false + t['routes'].each { |r| + if !vpc["virtual_name"] and + !vpc["create_nat_gateway"] and + !vpc['bastion'] and + r["gateway"] == "#NAT" + r["gateway"] = "#DENY" + end + is_public = true if r["gateway"] == "#INTERNET" + } count = 0 vpc['regions'].each { |r| block = blocks.shift - vpc["subnets"] << { + subnet = { "availability_zone" => r, "route_table" => t["name"], "ip_block" => block.to_s, - "name" => "Subnet"+count.to_s+t["name"].capitalize, - "map_public_ips" => true + "name" => "Subnet"+count.to_s+t["name"].capitalize } + if is_public + subnet["map_public_ips"] = true + subnet["is_public"] = true + end + vpc["subnets"] << subnet count = count + 1 } } end + vpc['subnets'].each { |s| + if !s['availability_zone'] + s['availability_zone'] = vpc['region'] + s['availability_zone'] ||= MU::Cloud::Google.myRegion(vpc['credentials']) + end + } + # Google VPCs can't have routes that are anything other than global # (they can be tied to individual instances by tags, but w/e). So we # decompose our VPCs into littler VPCs, one for each declared route @@ -617,6 +842,7 @@ def self.validateConfig(vpc, configurator) vpc['route_tables'].each { |tbl| newvpc = { "name" => vpc['name']+"-"+tbl['name'], + "cloud" => "Google", "credentials" => vpc['credentials'], "virtual_name" => vpc['name'], "ip_block" => blocks.shift, @@ -631,6 +857,11 @@ def self.validateConfig(vpc, configurator) next if ["name", "route_tables", "subnets", "ip_block"].include?(key) newvpc[key] = val } + if vpc["bastion"] and + !tbl["routes"].map { |r| r["gateway"] }.include?("#INTERNET") + newvpc["bastion"] = vpc["bastion"] + vpc.delete("bastion") + end newvpc['peers'] ||= [] # Add the peer connections we're generating, in addition peernames.each { |peer| @@ -645,6 +876,7 @@ def self.validateConfig(vpc, configurator) vpc["subnets"].each { |subnet| newvpc["subnets"] << subnet if subnet["route_table"] == tbl["name"] } + ok = false if !configurator.insertKitten(newvpc, "vpcs", true) } configurator.removeKitten(vpc['name'], "vpcs") @@ -658,7 +890,7 @@ def self.validateConfig(vpc, configurator) else ok = false if !genStandardSubnetACLs(vpc['parent_block'] || vpc['ip_block'], vpc['name'], configurator, vpc["project"], credentials: vpc['credentials']) end - if has_nat and !has_deny + if has_nat and !has_deny and !vpc['bastion'] vpc['route_tables'].first["routes"] << { "gateway"=>"#DENY", "destination_network"=>"0.0.0.0/0" @@ -678,29 +910,20 @@ def self.validateConfig(vpc, configurator) # No such thing as a NAT gateway in Google... so make an instance # that'll do the deed. if route['gateway'] == "#NAT" - nat_cfg = MU::Cloud::Google::Server.genericNAT - nat_cfg['name'] = vpc['name']+"-natstion-"+nat_count.to_s - nat_cfg['credentials'] = vpc['credentials'] - # XXX ingress/egress rules? - # XXX for master too if applicable - nat_cfg["application_attributes"] = { - "nat" => { - "private_net" => vpc["parent_block"].to_s - } - } - route['nat_host_name'] = nat_cfg['name'] - route['priority'] = 100 - vpc["dependencies"] << { - "type" => "server", - "name" => nat_cfg['name'], - } + # theoretically our upstream validation should have inserted + # a NAT/bastion host we can use + nat = configurator.haveLitterMate?(vpc['name']+"-natstion", "servers") + if vpc['virtual_name'] + nat ||= configurator.haveLitterMate?(vpc['virtual_name']+"-natstion", "servers") + end - nat_cfg['vpc'] = { - "vpc_name" => vpc["name"], - "subnet_pref" => "any" - } - nat_count = nat_count + 1 - ok = false if !configurator.insertKitten(nat_cfg, "servers", true) + if !nat + MU.log "Google VPC #{vpc['name']} declared a #NAT route, but I don't see an upstream NAT host I can use. Do I even have public subnets?", MU::ERR + ok = false + else + route['nat_host_name'] = vpc['name']+"-natstion" + route['priority'] = 100 + end end } end @@ -769,7 +992,7 @@ def createRoute(route, network: @url, tags: []) raise MuError, "Failed to find NAT host for #NAT route in #{@mu_name} (#{route})" end - routeobj = ::Google::Apis::ComputeBeta::Route.new( + routeobj = ::Google::Apis::ComputeV1::Route.new( name: routename, next_hop_instance: nat_instance.cloud_desc.self_link, dest_range: route['destination_network'], @@ -794,7 +1017,7 @@ def createRoute(route, network: @url, tags: []) } end elsif route['gateway'] == "#INTERNET" - routeobj = ::Google::Apis::ComputeBeta::Route.new( + routeobj = ::Google::Apis::ComputeV1::Route.new( name: routename, next_hop_gateway: "global/gateways/default-internet-gateway", dest_range: route['destination_network'], @@ -863,12 +1086,16 @@ def self.purge_subnets(noop = false, tagfilters = [{name: "tag:MU-ID", values: [ regions.each { |r| regionthreads << Thread.new { MU.dupGlobals(parent_thread_id) - MU::Cloud::Google.compute(credentials: credentials).delete( - "subnetwork", - project, - r, - noop - ) + begin + MU::Cloud::Google.compute(credentials: credentials).delete( + "subnetwork", + project, + r, + noop + ) + rescue MU::Cloud::MuDefunctHabitat => e + Thread.exit + end } } regionthreads.each do |t| @@ -888,12 +1115,12 @@ class Subnet < MU::Cloud::Google::VPC attr_reader :ip_block attr_reader :mu_name attr_reader :name + attr_reader :cloud_desc_cache attr_reader :az - # @param parent [MU::Cloud::Google::VPC]: The parent VPC of this subnet. # @param config [Hash]: - def initialize(parent, config) + def initialize(parent, config, precache_description: true) @parent = parent @config = MU::Config.manxify(config) @cloud_id = config['cloud_id'] @@ -903,20 +1130,32 @@ def initialize(parent, config) @deploydata = config # This is a dummy for the sake of describe() @az = config['az'] @ip_block = config['ip_block'] + @cloud_desc_cache = nil + cloud_desc if precache_description end # Return the cloud identifier for the default route of this subnet. def defaultRoute end + # Describe this VPC Subnet + # @return [Hash] + def notify + MU.structToHash(cloud_desc) + end + + # Describe this VPC Subnet from the cloud platform's perspective + # @return [Google::Apis::Core::Hashable] + def cloud_desc + @cloud_desc_cache ||= MU::Cloud::Google.compute(credentials: @parent.config['credentials']).get_subnetwork(@parent.habitat_id, @config['az'], @config['cloud_id']) + @cloud_desc_cache + end + # Is this subnet privately-routable only, or public? # @return [Boolean] def private? - routes = MU::Cloud::Google.compute(credentials: @parent.config['credentials']).list_routes( - @parent.config['project'], - filter: "network eq #{@parent.url}" - ).items - routes.map { |r| + @parent.cloud_desc + @parent.routes.map { |r| if r.dest_range == "0.0.0.0/0" and !r.next_hop_gateway.nil? and (r.tags.nil? or r.tags.size == 0) and r.next_hop_gateway.match(/\/global\/gateways\/default-internet-gateway/) diff --git a/modules/mu/config.rb b/modules/mu/config.rb index 84505d500..752a9fb65 100644 --- a/modules/mu/config.rb +++ b/modules/mu/config.rb @@ -28,6 +28,9 @@ class Config # Exception class for BoK parse or validation errors class ValidationError < MU::MuError end + # Exception class for duplicate resource names + class DuplicateNameError < MU::MuError + end # Exception class for deploy parameter (mu-deploy -p foo=bar) errors class DeployParamError < MuError end @@ -41,8 +44,6 @@ def self.defaultCloud if $MU_CFG[cloud.downcase] and !$MU_CFG[cloud.downcase].empty? configured[cloud] = $MU_CFG[cloud.downcase].size configured[cloud] += 0.5 if cloudclass.hosted? # tiebreaker - elsif cloudclass.hosted? - configured[cloud] = 1 end } if configured.size > 0 @@ -50,61 +51,22 @@ def self.defaultCloud configured[b] <=> configured[a] }.first else + MU::Cloud.supportedClouds.each { |cloud| + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud) + return cloud if cloudclass.hosted? + } return MU::Cloud.supportedClouds.first end end # The default grooming agent for new resources. Must exist in MU.supportedGroomers. def self.defaultGroomer - "Chef" + MU.localOnly ? "Ansible" : "Chef" end attr_accessor :nat_routes attr_reader :skipinitialupdates - attr_reader :google_images - @@google_images = YAML.load(File.read("#{MU.myRoot}/modules/mu/defaults/google_images.yaml")) - if File.exists?("#{MU.etcDir}/google_images.yaml") - custom = YAML.load(File.read("#{MU.etcDir}/google_images.yaml")) - @@google_images.merge!(custom) { |key, oldval, newval| - if !oldval.is_a?(Hash) and !newval.nil? - if !newval.nil? - newval - else - oldval - end - else - oldval.merge(newval) - end - } - end - # The list of known Google Images which we can use for a given platform - def self.google_images - @@google_images - end - - attr_reader :amazon_images - @@amazon_images = YAML.load(File.read("#{MU.myRoot}/modules/mu/defaults/amazon_images.yaml")) - if File.exists?("#{MU.etcDir}/amazon_images.yaml") - custom = YAML.load(File.read("#{MU.etcDir}/amazon_images.yaml")) - @@amazon_images.merge!(custom) { |key, oldval, newval| - if !oldval.is_a?(Hash) and !newval.nil? - if !newval.nil? - newval - else - oldval - end - else - oldval.merge(newval) - end - } - end - # The list of known Amazon AMIs, by region, which we can use for a given - # platform. - def self.amazon_images - @@amazon_images - end - @@config_path = nil # The path to the most recently loaded configuration file attr_reader :config_path @@ -123,20 +85,24 @@ def self.schema def self.schemaMerge(orig, new, cloud) if new.is_a?(Hash) new.each_pair { |k, v| + if cloud and k == "description" and v.is_a?(String) and !v.match(/\b#{Regexp.quote(cloud.upcase)}\b/) and !v.empty? + new[k] = "+"+cloud.upcase+"+: "+v + end if orig and orig.has_key?(k) - schemaMerge(orig[k], new[k], cloud) elsif orig orig[k] = new[k] else orig = new end + schemaMerge(orig[k], new[k], cloud) } elsif orig.is_a?(Array) and new orig.concat(new) orig.uniq! elsif new.is_a?(String) orig ||= "" - orig += "\n#{cloud.upcase}: "+new + orig += "\n" if !orig.empty? + orig += "+#{cloud.upcase}+: "+new else # XXX I think this is a NOOP? end @@ -171,8 +137,6 @@ def self.docSchema # recursively chase down description fields in arrays and objects of our # schema and prepend stuff to them for documentation def self.prepend_descriptions(prefix, cfg) -# cfg["description"] ||= "" -# cfg["description"] = prefix+cfg["description"] cfg["prefix"] = prefix if cfg["type"] == "array" and cfg["items"] cfg["items"] = prepend_descriptions(prefix, cfg["items"]) @@ -196,7 +160,9 @@ def self.prepend_descriptions(prefix, cfg) next if required.size == 0 and res_schema.size == 0 res_schema.each { |key, cfg| cfg["description"] ||= "" - cfg["description"] = "+"+cloud.upcase+"+: "+cfg["description"] + if !cfg["description"].empty? + cfg["description"] = "\n# +"+cloud.upcase+"+: "+cfg["description"] + end if docschema["properties"][attrs[:cfg_plural]]["items"]["properties"][key] schemaMerge(docschema["properties"][attrs[:cfg_plural]]["items"]["properties"][key], cfg, cloud) docschema["properties"][attrs[:cfg_plural]]["items"]["properties"][key]["description"] ||= "" @@ -205,6 +171,7 @@ def self.prepend_descriptions(prefix, cfg) else if only_children[attrs[:cfg_plural]][key] prefix = only_children[attrs[:cfg_plural]][key].keys.map{ |x| x.upcase }.join(" & ")+" ONLY" + cfg["description"].gsub!(/^\n#/, '') # so we don't leave the description blank in the "optional parameters" section cfg = prepend_descriptions(prefix, cfg) end @@ -245,21 +212,338 @@ def self.tails # layers that don't care about the metadata in Tails. # @param config [Hash]: The configuration tree to convert # @return [Hash]: The modified configuration - def self.manxify(config) + def self.manxify(config, remove_runtime_keys: false) if config.is_a?(Hash) + newhash = {} config.each_pair { |key, val| - config[key] = self.manxify(val) + next if remove_runtime_keys and key.match(/^#MU_/) + next if val.is_a?(Array) and val.empty? + newhash[key] = self.manxify(val, remove_runtime_keys: remove_runtime_keys) } + config = newhash elsif config.is_a?(Array) + newarray = [] config.each { |val| - val = self.manxify(val) + newarray << self.manxify(val, remove_runtime_keys: remove_runtime_keys) } + config = newarray elsif config.is_a?(MU::Config::Tail) return config.to_s + elsif config.is_a?(MU::Config::Ref) + return self.manxify(config.to_h, remove_runtime_keys: remove_runtime_keys) end return config end + # Make a deep copy of a config hash and pare it down to only primitive + # types, even at the leaves. + # @param config [Hash] + # @return [Hash] + def self.stripConfig(config) + MU::Config.manxify(Marshal.load(Marshal.dump(MU.structToHash(config.dup))), remove_runtime_keys: true) + end + + # A wrapper class for resources to refer to other resources, whether they + # be a sibling object in the current deploy, an object in another deploy, + # or a plain cloud id from outside of Mu. + class Ref + attr_reader :name + attr_reader :type + attr_reader :cloud + attr_reader :deploy_id + attr_reader :region + attr_reader :credentials + attr_reader :habitat + attr_reader :mommacat + attr_reader :tag_key + attr_reader :tag_value + attr_reader :obj + + @@refs = [] + @@ref_semaphore = Mutex.new + + # Little bit of a factory pattern... given a hash of options for a {MU::Config::Ref} objects, first see if we have an existing one that matches our more immutable attributes (+cloud+, +id+, etc). If we do, return that. If we do not, create one, add that to our inventory, and return that instead. + # @param cfg [Hash]: + # @return [MU::Config::Ref] + def self.get(cfg) + return cfg if cfg.is_a?(MU::Config::Ref) + checkfields = cfg.keys.map { |k| k.to_sym } + required = [:id, :type] + + @@ref_semaphore.synchronize { + match = nil + @@refs.each { |ref| + saw_mismatch = false + saw_match = false + needed_values = [] + checkfields.each { |field| + next if !cfg[field] + ext_value = ref.instance_variable_get("@#{field.to_s}".to_sym) + if !ext_value + needed_values << field + next + end + if cfg[field] != ext_value + saw_mismatch = true + elsif required.include?(field) and cfg[field] == ext_value + saw_match = true + end + } + if saw_match and !saw_mismatch + # populate empty fields we got from this request + if needed_values.size > 0 + newref = ref.dup + needed_values.each { |field| + newref.instance_variable_set("@#{field.to_s}".to_sym, cfg[field]) + if !newref.respond_to?(field) + newref.singleton_class.instance_eval { attr_reader field.to_sym } + end + } + @@refs << newref + return newref + else + return ref + end + end + } + + } + + # if we get here, there was no match + newref = MU::Config::Ref.new(cfg) + @@ref_semaphore.synchronize { + @@refs << newref + return newref + } + end + + # @param cfg [Hash]: A Basket of Kittens configuration hash containing + # lookup information for a cloud object + def initialize(cfg) + cfg.keys.each { |field| + next if field == "tag" + if !cfg[field].nil? + self.instance_variable_set("@#{field}".to_sym, cfg[field]) + elsif !cfg[field.to_sym].nil? + self.instance_variable_set("@#{field.to_s}".to_sym, cfg[field.to_sym]) + end + self.singleton_class.instance_eval { attr_reader field.to_sym } + } + if cfg['tag'] and cfg['tag']['key'] and + !cfg['tag']['key'].empty? and cfg['tag']['value'] + @tag_key = cfg['tag']['key'] + @tag_value = cfg['tag']['value'] + end + + if @deploy_id and !@mommacat + @mommacat = MU::MommaCat.getLitter(@deploy_id, set_context_to_me: false) + elsif @mommacat and !@deploy_id + @deploy_id = @mommacat.deploy_id + end + + kitten if @mommacat # try to populate the actual cloud object for this + end + + # Comparison operator + def <=>(other) + return 1 if other.nil? + self.to_s <=> other.to_s + end + + # Base configuration schema for declared kittens referencing other cloud objects. This is essentially a set of filters that we're going to pass to {MU::MommaCat.findStray}. + # @param aliases [Array]: Key => value mappings to set backwards-compatibility aliases for attributes, such as the ubiquitous +vpc_id+ (+vpc_id+ => +id+). + # @return [Hash] + def self.schema(aliases = [], type: nil, parent_obj: nil, desc: nil) + parent_obj ||= caller[1].gsub(/.*?\/([^\.\/]+)\.rb:.*/, '\1') + desc ||= "Reference a #{type ? "'#{type}' resource" : "resource" } from this #{parent_obj ? "'#{parent_obj}'" : "" } resource" + schema = { + "type" => "object", + "#MU_REFERENCE" => true, + "minProperties" => 1, + "description" => desc, + "properties" => { + "id" => { + "type" => "string", + "description" => "Cloud identifier of a resource we want to reference, typically used when leveraging resources not managed by MU" + }, + "name" => { + "type" => "string", + "description" => "The short (internal Mu) name of a resource we're attempting to reference. Typically used when referring to a sibling resource elsewhere in the same deploy, or in another known Mu deploy in conjunction with +deploy_id+." + }, + "type" => { + "type" => "string", + "description" => "The resource type we're attempting to reference.", + "enum" => MU::Cloud.resource_types.values.map { |t| t[:cfg_plural] } + }, + "deploy_id" => { + "type" => "string", + "description" => "Our target resource should be found in this Mu deploy." + }, + "credentials" => MU::Config.credentials_primitive, + "region" => MU::Config.region_primitive, + "cloud" => MU::Config.cloud_primitive, + "tag" => { + "type" => "object", + "description" => "If the target resource supports tagging and our resource implementations +find+ method supports it, we can attempt to locate it by tag.", + "properties" => { + "key" => { + "type" => "string", + "description" => "The tag or label key to search against" + }, + "value" => { + "type" => "string", + "description" => "The tag or label value to match" + } + } + } + } + } + if !["folders", "habitats"].include?(type) + schema["properties"]["habitat"] = MU::Config::Habitat.reference + end + + if !type.nil? + schema["required"] = ["type"] + schema["properties"]["type"]["default"] = type + schema["properties"]["type"]["enum"] = [type] + end + + aliases.each { |a| + a.each_pair { |k, v| + if schema["properties"][v] + schema["properties"][k] = schema["properties"][v].dup + schema["properties"][k]["description"] = "Alias for #{v}" + else + MU.log "Reference schema alias #{k} wants to alias #{v}, but no such attribute exists", MU::WARN, details: caller[4] + end + } + } + + schema + end + + # Decompose into a plain-jane {MU::Config::BasketOfKittens} hash fragment, + # of the sort that would have been used to declare this reference in the + # first place. + def to_h + me = { } + + self.instance_variables.each { |var| + next if [:@obj, :@mommacat, :@tag_key, :@tag_value].include?(var) + val = self.instance_variable_get(var) + next if val.nil? + val = val.to_h if val.is_a?(MU::Config::Ref) + me[var.to_s.sub(/^@/, '')] = val + } + if @tag_key and !@tag_key.empty? + me['tag'] = { + 'key' => @tag_key, + 'value' => @tag_value + } + end + me + end + + # Getter for the #{id} instance variable that attempts to populate it if + # it's not set. + # @return [String,nil] + def id + return @id if @id + kitten # if it's not defined, attempt to define it + @id + end + + # Alias for {id} + # @return [String,nil] + def cloud_id + id + end + + # Return a {MU::Cloud} object for this reference. This is only meant to be + # called in a live deploy, which is to say that if called during initial + # configuration parsing, results may be incorrect. + # @param mommacat [MU::MommaCat]: A deploy object which will be searched for the referenced resource if provided, before restoring to broader, less efficient searches. + def kitten(mommacat = @mommacat) + return nil if !@cloud or !@type + + if @obj + @deploy_id ||= @obj.deploy_id + @id ||= @obj.cloud_id + @name ||= @obj.config['name'] + return @obj + end + + if mommacat + @obj = mommacat.findLitterMate(type: @type, name: @name, cloud_id: @id, credentials: @credentials, debug: false) + if @obj # initialize missing attributes, if we can + @id ||= @obj.cloud_id + @mommacat ||= mommacat + @obj.intoDeploy(@mommacat) # make real sure these are set + @deploy_id ||= mommacat.deploy_id + if !@name + if @obj.config and @obj.config['name'] + @name = @obj.config['name'] + elsif @obj.mu_name +if @type == "folders" +MU.log "would assign name '#{@obj.mu_name}' in ref to this folder if I were feeling aggressive", MU::WARN, details: self.to_h +end +# @name = @obj.mu_name + end + end + return @obj + else +# MU.log "Failed to find a live '#{@type.to_s}' object named #{@name}#{@id ? " (#{@id})" : "" }#{ @habitat ? " in habitat #{@habitat}" : "" }", MU::WARN, details: self + end + end + + if !@obj and !(@cloud == "Google" and @id and @type == "users" and MU::Cloud::Google::User.cannedServiceAcctName?(@id)) + + begin + hab_arg = if @habitat.nil? + [nil] + elsif @habitat.is_a?(MU::Config::Ref) + [@habitat.id] + elsif @habitat.is_a?(Hash) + [@habitat["id"]] + else + [@habitat.to_s] + end + + found = MU::MommaCat.findStray( + @cloud, + @type, + name: @name, + cloud_id: @id, + deploy_id: @deploy_id, + region: @region, + habitats: hab_arg, + credentials: @credentials, + dummy_ok: (["habitats", "folders", "users", "groups"].include?(@type)) + ) + @obj ||= found.first if found + rescue ThreadError => e + # Sometimes MommaCat calls us in a potential deadlock situation; + # don't be the cause of a fatal error if so, we don't need this + # object that badly. + raise e if !e.message.match(/recursive locking/) +rescue SystemExit => e +# XXX this is temporary, to cope with some debug stuff that's in findStray +# for the nonce +return + end + end + + if @obj + @deploy_id ||= @obj.deploy_id + @id ||= @obj.cloud_id + @name ||= @obj.config['name'] + end + + @obj + end + + end + # A wrapper for config leaves that came from ERB parameters instead of raw # YAML or JSON. Will behave like a string for things that expect that # sort of thing. Code that needs to know that this leaf was the result of @@ -287,6 +571,7 @@ class Tail def initialize(name, value, prettyname = nil, cloudtype = "String", valid_values = [], description = "", is_list_element = false, prefix: "", suffix: "", pseudo: false, runtimecode: nil, index: 0) @name = name + @bindings = {} @value = value @valid_values = valid_values @pseudo = pseudo @@ -324,7 +609,7 @@ def getPrettyName end # Walk like a String def to_s - @prefix+@value+@suffix + @prefix.to_s+@value.to_s+@suffix.to_s end # Quack like a String def to_str @@ -350,6 +635,11 @@ def match(*args) def ==(o) (o.class == self.class or o.class == "String") && o.to_s == to_s end + # Concatenate like a string + def +(o) + return to_s if o.nil? + to_s + o.to_s + end # Perform global substitutions like a String def gsub(*args) to_s.gsub(*args) @@ -437,6 +727,7 @@ def method_missing(var_name) "MU::Config.getTail PLACEHOLDER #{var_name} REDLOHECALP" else tail = getTail(var_name.to_s) + if tail.is_a?(Array) if @param_pass return tail.map {|f| f.values.first.to_s }.join(",") @@ -446,7 +737,11 @@ def method_missing(var_name) return "MU::Config.getTail PLACEHOLDER #{var_name} REDLOHECALP" end else - return "MU::Config.getTail PLACEHOLDER #{var_name} REDLOHECALP" + if @param_pass + tail.to_s + else + return "MU::Config.getTail PLACEHOLDER #{var_name} REDLOHECALP" + end end end end @@ -470,13 +765,28 @@ def cloudCode(code, placeholder = "CLOUDCODEPLACEHOLDER") "MU::Config.getTail PLACEHOLDER #{var_name} REDLOHECALP" end + # Make sure our parameter values are all available in the local namespace + # that ERB will be using, minus any that conflict with existing variables + erb_binding = get_binding(@@tails.keys.sort) + @@tails.each_pair { |key, tail| + next if !tail.is_a?(MU::Config::Tail) or tail.is_list_element + # XXX figure out what to do with lists + begin + erb_binding.local_variable_set(key.to_sym, tail.to_s) + rescue NameError + MU.log "Binding #{key} = #{tail.to_s}", MU::DEBUG + erb_binding.local_variable_set(key.to_sym, tail.to_s) + end + } + # Figure out what kind of file we're loading. We handle includes # differently if YAML is involved. These globals get used inside # templates. They're globals on purpose. Stop whining. $file_format = MU::Config.guessFormat(path) $yaml_refs = {} erb = ERB.new(File.read(path), nil, "<>") - raw_text = erb.result(get_binding) + + raw_text = erb.result(erb_binding) raw_json = nil # If we're working in YAML, do some magic to make includes work better. @@ -494,7 +804,7 @@ def cloudCode(code, placeholder = "CLOUDCODEPLACEHOLDER") begin config = JSON.parse(raw_json) - if param_pass + if param_pass and config.is_a?(Hash) config.keys.each { |key| if key != "parameters" if key == "appname" and @@parameters["myAppName"].nil? @@ -505,7 +815,7 @@ def cloudCode(code, placeholder = "CLOUDCODEPLACEHOLDER") config.delete(key) end } - else + elsif config.is_a?(Hash) config.delete("parameters") end rescue JSON::ParserError => e @@ -526,6 +836,7 @@ def cloudCode(code, placeholder = "CLOUDCODEPLACEHOLDER") attr_reader :kittens attr_reader :updating + attr_reader :existing_deploy attr_reader :kittencfg_semaphore # Load, resolve, and validate a configuration file ("Basket of Kittens"). @@ -533,7 +844,7 @@ def cloudCode(code, placeholder = "CLOUDCODEPLACEHOLDER") # @param skipinitialupdates [Boolean]: Whether to forcibly apply the *skipinitialupdates* flag to nodes created by this configuration. # @param params [Hash]: Optional name-value parameter pairs, which will be passed to our configuration files as ERB variables. # @return [Hash]: The complete validated configuration for a deployment. - def initialize(path, skipinitialupdates = false, params: params = Hash.new, updating: nil) + def initialize(path, skipinitialupdates = false, params: {}, updating: nil, default_credentials: nil) $myPublicIp = MU::Cloud::AWS.getAWSMetaData("public-ipv4") $myRoot = MU.myRoot $myRoot.freeze @@ -551,6 +862,10 @@ def initialize(path, skipinitialupdates = false, params: params = Hash.new, upda @admin_firewall_rules = [] @skipinitialupdates = skipinitialupdates @updating = updating + if @updating + @existing_deploy = MU::MommaCat.new(@updating) + end + @default_credentials = default_credentials ok = true params.each_pair { |name, value| @@ -601,15 +916,19 @@ def initialize(path, skipinitialupdates = false, params: params = Hash.new, upda elsif param["required"] or !param.has_key?("required") MU.log "Required parameter '#{param['name']}' not supplied", MU::ERR ok = false - end - if param.has_key?("cloudtype") - getTail(param['name'], value: @@parameters[param['name']], cloudtype: param["cloudtype"], valid_values: param['valid_values'], description: param['description'], prettyname: param['prettyname'], list_of: param['list_of']) - else - getTail(param['name'], value: @@parameters[param['name']], valid_values: param['valid_values'], description: param['description'], prettyname: param['prettyname'], list_of: param['list_of']) + next + else # not required, no default + next end end + if param.has_key?("cloudtype") + getTail(param['name'], value: @@parameters[param['name']], cloudtype: param["cloudtype"], valid_values: param['valid_values'], description: param['description'], prettyname: param['prettyname'], list_of: param['list_of']) + else + getTail(param['name'], value: @@parameters[param['name']], valid_values: param['valid_values'], description: param['description'], prettyname: param['prettyname'], list_of: param['list_of']) + end } end + raise ValidationError if !ok @@parameters.each_pair { |name, val| next if @@tails.has_key?(name) and @@tails[name].is_a?(MU::Config::Tail) and @@tails[name].pseudo @@ -626,13 +945,13 @@ def initialize(path, skipinitialupdates = false, params: params = Hash.new, upda MU.log "Passing variable '#{name}' into #{path} with value '#{val}'" } raise DeployParamError, "One or more invalid parameters specified" if !ok - $parameters = @@parameters + $parameters = @@parameters.dup $parameters.freeze tmp_cfg, raw_erb = resolveConfig(path: @@config_path) # Convert parameter entries that constitute whole config keys into - # MU::Config::Tail objects. + # {MU::Config::Tail} objects. def resolveTails(tree, indent= "") if tree.is_a?(Hash) tree.each_pair { |key, val| @@ -663,19 +982,32 @@ def resolveTails(tree, indent= "") } ] end - MU::Config.set_defaults(@config, MU::Config.schema) + + @config['credentials'] ||= @default_credentials + + types = MU::Cloud.resource_types.values.map { |v| v[:cfg_plural] } + + MU::Cloud.resource_types.values.map { |v| v[:cfg_plural] }.each { |type| + if @config[type] + @config[type].each { |k| + applyInheritedDefaults(k, type) + } + end + } + applySchemaDefaults(@config, MU::Config.schema) + validate # individual resources validate when added now, necessary because the schema can change depending on what cloud they're targeting # XXX but now we're not validating top-level keys, argh #pp @config #raise "DERP" - return @config.freeze + @config.freeze end # Output the dependencies of this BoK stack as a directed acyclic graph. # Very useful for debugging. def visualizeDependencies # GraphViz won't like MU::Config::Tail, pare down to plain Strings - config = MU::Config.manxify(Marshal.load(Marshal.dump(@config))) + config = MU::Config.stripConfig(@config) begin g = GraphViz.new(:G, :type => :digraph) # Generate a GraphViz node for each resource in this stack @@ -710,21 +1042,80 @@ def visualizeDependencies end end + # Generate a documentation-friendly dummy Ruby class for our mu.yaml main + # config. + def self.emitConfigAsRuby + example = %Q{--- +public_address: 1.2.3.4 +mu_admin_email: egtlabs@eglobaltech.com +mu_admin_name: Joe Schmoe +mommacat_port: 2260 +banner: My Example Mu Master +mu_repository: git://github.com/cloudamatic/mu.git +repos: +- https://github.com/cloudamatic/mu_demo_platform +allow_invade_foreign_vpcs: true +ansible_dir: +aws: + egtdev: + region: us-east-1 + log_bucket_name: egt-mu-log-bucket + default: true + name: egtdev + personal: + region: us-east-2 + log_bucket_name: my-mu-log-bucket + name: personal + google: + egtlabs: + project: egt-labs-admin + credentials_file: /opt/mu/etc/google.json + region: us-east4 + log_bucket_name: hexabucket-761234 + default: true +} + mu_yaml_schema = eval(%Q{ +$NOOP = true +load "#{MU.myRoot}/bin/mu-configure" +$CONFIGURABLES +}) + return if mu_yaml_schema.nil? or !mu_yaml_schema.is_a?(Hash) + muyamlpath = "#{MU.myRoot}/modules/mu/mu.yaml.rb" + MU.log "Converting mu.yaml schema to Ruby objects in #{muyamlpath}" + muyaml_rb = File.new(muyamlpath, File::CREAT|File::TRUNC|File::RDWR, 0644) + muyaml_rb.puts "# Configuration schema for mu.yaml. See also {https://github.com/cloudamatic/mu/wiki/Configuration the Mu wiki}." + muyaml_rb.puts "#" + muyaml_rb.puts "# Example:" + muyaml_rb.puts "#" + muyaml_rb.puts "#
"
+      example.split(/\n/).each { |line|
+        muyaml_rb.puts "#      "+line+"    " # markdooooown
+      }
+      muyaml_rb.puts "# 
" + muyaml_rb.puts "module MuYAML" + muyaml_rb.puts "\t# The configuration file format for Mu's main config file." + self.printMuYamlSchema(muyaml_rb, [], { "subtree" => mu_yaml_schema }) + muyaml_rb.puts "end" + muyaml_rb.close + end + # Take the schema we've defined and create a dummy Ruby class tree out of # it, basically so we can leverage Yard to document it. def self.emitSchemaAsRuby kittenpath = "#{MU.myRoot}/modules/mu/kittens.rb" MU.log "Converting Basket of Kittens schema to Ruby objects in #{kittenpath}" - dummy_kitten_class = File.new(kittenpath, File::CREAT|File::TRUNC|File::RDWR, 0644) - dummy_kitten_class.puts "### THIS FILE IS AUTOMATICALLY GENERATED, DO NOT EDIT ###" - dummy_kitten_class.puts "" - dummy_kitten_class.puts "module MU" - dummy_kitten_class.puts "class Config" - dummy_kitten_class.puts "\t# The configuration file format for Mu application stacks." - self.printSchema(dummy_kitten_class, ["BasketofKittens"], MU::Config.docSchema) - dummy_kitten_class.puts "end" - dummy_kitten_class.puts "end" - dummy_kitten_class.close + kitten_rb = File.new(kittenpath, File::CREAT|File::TRUNC|File::RDWR, 0644) + kitten_rb.puts "### THIS FILE IS AUTOMATICALLY GENERATED, DO NOT EDIT ###" + kitten_rb.puts "#" + kitten_rb.puts "#" + kitten_rb.puts "#" + kitten_rb.puts "module MU" + kitten_rb.puts "class Config" + kitten_rb.puts "\t# The configuration file format for Mu application stacks." + self.printSchema(kitten_rb, ["BasketofKittens"], MU::Config.docSchema) + kitten_rb.puts "end" + kitten_rb.puts "end" + kitten_rb.close end @@ -741,7 +1132,6 @@ def divideNetwork(ip_block, subnets_desired, max_mask = 28) subnet_bits = cidr.netmask.prefix_len begin subnet_bits += 1 - if subnet_bits > max_mask MU.log "Can't subdivide #{cidr.to_s} into #{subnets_desired.to_s}", MU::ERR raise MuError, "Subnets smaller than /#{max_mask} not permitted" @@ -781,7 +1171,9 @@ def haveLitterMate?(name, type, has_multiple: false) shortclass, cfg_name, cfg_plural, classname = MU::Cloud.getResourceNames(type) if @kittens[cfg_plural] @kittens[cfg_plural].each { |kitten| - if kitten['name'] == name.to_s or kitten['virtual_name'] == name.to_s + if kitten['name'].to_s == name.to_s or + kitten['virtual_name'].to_s == name.to_s or + (has_multiple and name.nil?) if has_multiple matches << kitten else @@ -821,7 +1213,7 @@ def removeKitten(name, type) # an extra pass to make sure we get all intra-stack dependencies correct. # @param acl [Hash]: The configuration hash for the FirewallRule to check # @return [Hash] - def resolveIntraStackFirewallRefs(acl) + def resolveIntraStackFirewallRefs(acl, delay_validation = false) acl["rules"].each { |acl_include| if acl_include['sgs'] acl_include['sgs'].each { |sg_ref| @@ -844,7 +1236,7 @@ def resolveIntraStackFirewallRefs(acl) siblingfw = haveLitterMate?(sg_ref, "firewall_rules") if !siblingfw["#MU_VALIDATED"] # XXX raise failure somehow - insertKitten(siblingfw, "firewall_rules") + insertKitten(siblingfw, "firewall_rules", delay_validation: delay_validation) end end } @@ -857,11 +1249,23 @@ def resolveIntraStackFirewallRefs(acl) # @param descriptor [Hash]: The configuration description, as from a Basket of Kittens # @param type [String]: The type of resource being added # @param delay_validation [Boolean]: Whether to hold off on calling the resource's validateConfig method - def insertKitten(descriptor, type, delay_validation = false) + # @param ignore_duplicates [Boolean]: Do not raise an exception if we attempt to insert a resource with a +name+ field that's already in use + def insertKitten(descriptor, type, delay_validation = false, ignore_duplicates: false, overwrite: false) append = false + start = Time.now + shortclass, cfg_name, cfg_plural, classname = MU::Cloud.getResourceNames(type) + MU.log "insertKitten on #{cfg_name} #{descriptor['name']} (delay_validation: #{delay_validation.to_s})", MU::DEBUG, details: caller[0] + + if overwrite + removeKitten(descriptor['name'], type) + end + + if !ignore_duplicates and haveLitterMate?(descriptor['name'], cfg_name) +# raise DuplicateNameError, "A #{shortclass} named #{descriptor['name']} has already been inserted into this configuration" + end @kittencfg_semaphore.synchronize { - append = !@kittens[type].include?(descriptor) + append = !@kittens[cfg_plural].include?(descriptor) # Skip if this kitten has already been validated and appended if !append and descriptor["#MU_VALIDATED"] @@ -870,10 +1274,26 @@ def insertKitten(descriptor, type, delay_validation = false) } ok = true - shortclass, cfg_name, cfg_plural, classname = MU::Cloud.getResourceNames(type) descriptor["#MU_CLOUDCLASS"] = classname - inheritDefaults(descriptor, cfg_plural) + + applyInheritedDefaults(descriptor, cfg_plural) + + # Meld defaults from our global schema and, if applicable, from our + # cloud-specific schema. schemaclass = Object.const_get("MU").const_get("Config").const_get(shortclass) + myschema = Marshal.load(Marshal.dump(MU::Config.schema["properties"][cfg_plural]["items"])) + more_required, more_schema = Object.const_get("MU").const_get("Cloud").const_get(descriptor["cloud"]).const_get(shortclass.to_s).schema(self) + if more_schema + MU::Config.schemaMerge(myschema["properties"], more_schema, descriptor["cloud"]) + end + myschema["required"] ||= [] + if more_required + myschema["required"].concat(more_required) + myschema["required"].uniq! + end + + descriptor = applySchemaDefaults(descriptor, myschema, type: shortclass) + MU.log "Schema check on #{descriptor['cloud']} #{cfg_name} #{descriptor['name']}", MU::DEBUG, details: myschema if (descriptor["region"] and descriptor["region"].empty?) or (descriptor['cloud'] == "Google" and ["firewall_rule", "vpc"].include?(cfg_name)) @@ -881,8 +1301,8 @@ def insertKitten(descriptor, type, delay_validation = false) end # Make sure a sensible region has been targeted, if applicable + classobj = Object.const_get("MU").const_get("Cloud").const_get(descriptor["cloud"]) if descriptor["region"] - classobj = Object.const_get("MU").const_get("Cloud").const_get(descriptor["cloud"]) valid_regions = classobj.listRegions if !valid_regions.include?(descriptor["region"]) MU.log "Known regions for cloud '#{descriptor['cloud']}' do not include '#{descriptor["region"]}'", MU::ERR, details: valid_regions @@ -890,8 +1310,10 @@ def insertKitten(descriptor, type, delay_validation = false) end end - if descriptor['project'] - if haveLitterMate?(descriptor['project'], "habitats") + if descriptor.has_key?('project') + if descriptor['project'].nil? + descriptor.delete('project') + elsif haveLitterMate?(descriptor['project'], "habitats") descriptor['dependencies'] ||= [] descriptor['dependencies'] << { "type" => "habitat", @@ -902,6 +1324,16 @@ def insertKitten(descriptor, type, delay_validation = false) # Does this resource go in a VPC? if !descriptor["vpc"].nil? and !delay_validation + # Quietly fix old vpc reference style + if descriptor['vpc']['vpc_id'] + descriptor['vpc']['id'] ||= descriptor['vpc']['vpc_id'] + descriptor['vpc'].delete('vpc_id') + end + if descriptor['vpc']['vpc_name'] + descriptor['vpc']['name'] = descriptor['vpc']['vpc_name'] + descriptor['vpc'].delete('vpc_name') + end + descriptor['vpc']['cloud'] = descriptor['cloud'] if descriptor['credentials'] descriptor['vpc']['credentials'] ||= descriptor['credentials'] @@ -911,30 +1343,41 @@ def insertKitten(descriptor, type, delay_validation = false) end # If we're using a VPC in this deploy, set it as a dependency - if !descriptor["vpc"]["vpc_name"].nil? and - haveLitterMate?(descriptor["vpc"]["vpc_name"], "vpcs") and + if !descriptor["vpc"]["name"].nil? and + haveLitterMate?(descriptor["vpc"]["name"], "vpcs") and descriptor["vpc"]['deploy_id'].nil? and - descriptor["vpc"]['vpc_id'].nil? + descriptor["vpc"]['id'].nil? descriptor["dependencies"] << { "type" => "vpc", - "name" => descriptor["vpc"]["vpc_name"] + "name" => descriptor["vpc"]["name"], } + siblingvpc = haveLitterMate?(descriptor["vpc"]["name"], "vpcs") + + if siblingvpc and siblingvpc['bastion'] and + ["server", "server_pool", "container_cluster"].include?(cfg_name) and + !descriptor['bastion'] + if descriptor['name'] != siblingvpc['bastion'].to_h['name'] + descriptor["dependencies"] << { + "type" => "server", + "name" => siblingvpc['bastion'].to_h['name'] + } + end + end - siblingvpc = haveLitterMate?(descriptor["vpc"]["vpc_name"], "vpcs") # things that live in subnets need their VPCs to be fully # resolved before we can proceed if ["server", "server_pool", "loadbalancer", "database", "cache_cluster", "container_cluster", "storage_pool"].include?(cfg_name) if !siblingvpc["#MU_VALIDATED"] - ok = false if !insertKitten(siblingvpc, "vpcs") + ok = false if !insertKitten(siblingvpc, "vpcs", overwrite: overwrite) end end if !MU::Config::VPC.processReference(descriptor['vpc'], cfg_plural, - shortclass.to_s+" '#{descriptor['name']}'", + descriptor, self, dflt_region: descriptor['region'], - is_sibling: true, credentials: descriptor['credentials'], + dflt_project: descriptor['project'], sibling_vpcs: @kittens['vpcs']) ok = false end @@ -943,12 +1386,13 @@ def insertKitten(descriptor, type, delay_validation = false) # thing exists, and also fetch its id now so later search routines # don't have to work so hard. else - if !MU::Config::VPC.processReference(descriptor["vpc"], cfg_plural, - "#{shortclass} #{descriptor['name']}", + if !MU::Config::VPC.processReference(descriptor["vpc"], + cfg_plural, + descriptor, self, credentials: descriptor['credentials'], + dflt_project: descriptor['project'], dflt_region: descriptor['region']) - MU.log "insertKitten was called from #{caller[0]}", MU::ERR ok = false end end @@ -975,25 +1419,39 @@ def insertKitten(descriptor, type, delay_validation = false) # Does it have generic ingress rules? fwname = cfg_name+descriptor['name'] - if !haveLitterMate?(fwname, "firewall_rules") and - (descriptor['ingress_rules'] or - ["server", "server_pool", "database"].include?(cfg_name)) + if (descriptor['ingress_rules'] or + ["server", "server_pool", "database", "cache_cluster"].include?(cfg_name)) descriptor['ingress_rules'] ||= [] + fw_classobj = Object.const_get("MU").const_get("Cloud").const_get(descriptor["cloud"]).const_get("FirewallRule") - acl = { + acl = haveLitterMate?(fwname, "firewall_rules") + already_exists = !acl.nil? + + acl ||= { "name" => fwname, "rules" => descriptor['ingress_rules'], "region" => descriptor['region'], "credentials" => descriptor["credentials"] } - acl["vpc"] = descriptor['vpc'].dup if descriptor['vpc'] + if !fw_classobj.isGlobal? + acl['region'] = descriptor['region'] + acl['region'] ||= classobj.myRegion(acl['credentials']) + else + acl.delete("region") + end + if descriptor["vpc"] + acl["vpc"] = descriptor['vpc'].dup + acl["vpc"].delete("subnet_pref") + end + ["optional_tags", "tags", "cloud", "project"].each { |param| acl[param] = descriptor[param] if descriptor[param] } - descriptor["add_firewall_rules"] = [] if descriptor["add_firewall_rules"].nil? - descriptor["add_firewall_rules"] << {"rule_name" => fwname} - acl = resolveIntraStackFirewallRefs(acl) - ok = false if !insertKitten(acl, "firewall_rules") + descriptor["add_firewall_rules"] ||= [] + descriptor["add_firewall_rules"] << {"rule_name" => fwname, "type" => "firewall_rules" } # XXX why the duck is there a type argument required here? + + acl = resolveIntraStackFirewallRefs(acl, delay_validation) + ok = false if !insertKitten(acl, "firewall_rules", delay_validation, overwrite: already_exists) end # Does it declare association with any sibling LoadBalancers? @@ -1028,10 +1486,6 @@ def insertKitten(descriptor, type, delay_validation = false) "type" => "firewall_rule", "name" => acl_include["rule_name"] } - siblingfw = haveLitterMate?(acl_include["rule_name"], "firewall_rules") - if !siblingfw["#MU_VALIDATED"] - ok = false if !insertKitten(siblingfw, "firewall_rules") - end elsif acl_include["rule_name"] MU.log shortclass.to_s+" #{descriptor['name']} depends on FirewallRule #{acl_include["rule_name"]}, but no such rule declared.", MU::ERR ok = false @@ -1043,13 +1497,14 @@ def insertKitten(descriptor, type, delay_validation = false) if descriptor["alarms"] && !descriptor["alarms"].empty? descriptor["alarms"].each { |alarm| alarm["name"] = "#{cfg_name}-#{descriptor["name"]}-#{alarm["name"]}" - alarm['dimensions'] = [] if !alarm['dimensions'] + alarm['dimensions'] ||= [] + alarm["namespace"] ||= descriptor['name'] alarm["credentials"] = descriptor["credentials"] alarm["#TARGETCLASS"] = cfg_name alarm["#TARGETNAME"] = descriptor['name'] alarm['cloud'] = descriptor['cloud'] - ok = false if !insertKitten(alarm, "alarms", true) + ok = false if !insertKitten(alarm, "alarms", true, overwrite: overwrite) } descriptor.delete("alarms") end @@ -1103,21 +1558,9 @@ def insertKitten(descriptor, type, delay_validation = false) # here ok = false if !schemaclass.validate(descriptor, self) - # Merge the cloud-specific JSON schema and validate against it - myschema = Marshal.load(Marshal.dump(MU::Config.schema["properties"][cfg_plural]["items"])) - more_required, more_schema = Object.const_get("MU").const_get("Cloud").const_get(descriptor["cloud"]).const_get(shortclass.to_s).schema(self) - - if more_schema - MU::Config.schemaMerge(myschema["properties"], more_schema, descriptor["cloud"]) - MU::Config.set_defaults(descriptor, myschema) - end - myschema["required"] ||= [] - myschema["required"].concat(more_required) - myschema["required"].uniq! - MU.log "Schema check on #{descriptor['cloud']} #{cfg_name} #{descriptor['name']}", MU::DEBUG, details: myschema - - plain_cfg = MU::Config.manxify(Marshal.load(Marshal.dump(descriptor))) + plain_cfg = MU::Config.stripConfig(descriptor) plain_cfg.delete("#MU_CLOUDCLASS") + plain_cfg.delete("#MU_VALIDATION_ATTEMPTED") plain_cfg.delete("#TARGETCLASS") plain_cfg.delete("#TARGETNAME") plain_cfg.delete("parent_block") if cfg_plural == "vpcs" @@ -1143,17 +1586,23 @@ def insertKitten(descriptor, type, delay_validation = false) # on stuff that will cause spurious alarms further in if ok parser = Object.const_get("MU").const_get("Cloud").const_get(descriptor["cloud"]).const_get(shortclass.to_s) - plain_descriptor = MU::Config.manxify(Marshal.load(Marshal.dump(descriptor))) - passed = parser.validateConfig(plain_descriptor, self) + original_descriptor = MU::Config.stripConfig(descriptor) + passed = parser.validateConfig(descriptor, self) - if passed - descriptor.merge!(plain_descriptor) - else + if !passed + descriptor = original_descriptor ok = false end + + # Make sure we've been configured with the right credentials + cloudbase = Object.const_get("MU").const_get("Cloud").const_get(descriptor['cloud']) + credcfg = cloudbase.credConfig(descriptor['credentials']) + if !credcfg or credcfg.empty? + raise ValidationError, "#{descriptor['cloud']} #{cfg_name} #{descriptor['name']} declares credential set #{descriptor['credentials']}, but no such credentials exist for that cloud provider" + end + descriptor['#MU_VALIDATED'] = true end - end descriptor["dependencies"].uniq! @@ -1161,13 +1610,15 @@ def insertKitten(descriptor, type, delay_validation = false) @kittencfg_semaphore.synchronize { @kittens[cfg_plural] << descriptor if append } + ok end @@allregions = [] - MU::Cloud.supportedClouds.each { |cloud| + MU::Cloud.availableClouds.each { |cloud| cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud) - @@allregions.concat(cloudclass.listRegions()) + regions = cloudclass.listRegions() + @@allregions.concat(regions) if regions } # Configuration chunk for choosing a provider region @@ -1175,8 +1626,9 @@ def insertKitten(descriptor, type, delay_validation = false) def self.region_primitive if !@@allregions or @@allregions.empty? @@allregions = [] - MU::Cloud.supportedClouds.each { |cloud| + MU::Cloud.availableClouds.each { |cloud| cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud) + return @allregions if !cloudclass.listRegions() @@allregions.concat(cloudclass.listRegions()) } end @@ -1214,7 +1666,7 @@ def self.tags_primitive "type" => "array", "minItems" => 1, "items" => { - "description" => "Tags to apply to this resource. Will apply at the cloud provider level and in Chef, where applicable.", + "description" => "Tags to apply to this resource. Will apply at the cloud provider level and in node groomers, where applicable.", "type" => "object", "title" => "tags", "required" => ["key", "value"], @@ -1236,7 +1688,7 @@ def self.tags_primitive def self.cloud_primitive { "type" => "string", - "default" => MU::Config.defaultCloud, +# "default" => MU::Config.defaultCloud, # applyInheritedDefaults does this better "enum" => MU::Cloud.supportedClouds } end @@ -1250,7 +1702,7 @@ def self.cloud_primitive # @param cloud [String]: The parent resource's cloud plugin identifier # @param region [String]: Cloud provider region, if applicable. # @return [Hash]: A dependency description that the calling resource can then add to itself. - def adminFirewallRuleset(vpc: nil, admin_ip: nil, region: nil, cloud: nil, credentials: nil) + def adminFirewallRuleset(vpc: nil, admin_ip: nil, region: nil, cloud: nil, credentials: nil, rules_only: false) if !cloud or (cloud == "AWS" and !region) raise MuError, "Cannot call adminFirewallRuleset without specifying the parent's region and cloud provider" end @@ -1259,27 +1711,6 @@ def adminFirewallRuleset(vpc: nil, admin_ip: nil, region: nil, cloud: nil, crede hosts << "#{MU.my_private_ip}/32" if MU.my_private_ip hosts << "#{MU.mu_public_ip}/32" if MU.mu_public_ip hosts << "#{admin_ip}/32" if admin_ip - hosts.uniq! - name = "admin" - name += credentials.to_s if credentials - realvpc = nil - - if vpc - realvpc = {} - realvpc['vpc_id'] = vpc['vpc_id'] if !vpc['vpc_id'].nil? - realvpc['vpc_name'] = vpc['vpc_name'] if !vpc['vpc_name'].nil? - realvpc['deploy_id'] = vpc['deploy_id'] if !vpc['deploy_id'].nil? - if !realvpc['vpc_id'].nil? and !realvpc['vpc_id'].empty? - # Stupid kludge for Google cloud_ids which are sometimes URLs and - # sometimes not. Requirements are inconsistent from scenario to - # scenario. - name = name + "-" + realvpc['vpc_id'].gsub(/.*\//, "") - realvpc['vpc_id'] = getTail("vpc_id", value: realvpc['vpc_id'], prettyname: "Admin Firewall Ruleset #{name} Target VPC", cloudtype: "AWS::EC2::VPC::Id") if realvpc["vpc_id"].is_a?(String) - elsif !realvpc['vpc_name'].nil? - name = name + "-" + realvpc['vpc_name'] - end - end - hosts.uniq! rules = [] @@ -1296,9 +1727,43 @@ def adminFirewallRuleset(vpc: nil, admin_ip: nil, region: nil, cloud: nil, crede ] end + resclass = Object.const_get("MU").const_get("Cloud").const_get(cloud).const_get("FirewallRule") + + if rules_only + return rules + end + + name = "admin" + name += credentials.to_s if credentials + realvpc = nil + if vpc + realvpc = {} + ['vpc_name', 'vpc_id'].each { |p| + if vpc[p] + vpc[p.sub(/^vpc_/, '')] = vpc[p] + vpc.delete(p) + end + } + ['cloud', 'id', 'name', 'deploy_id', 'habitat', 'credentials'].each { |field| + realvpc[field] = vpc[field] if !vpc[field].nil? + } + if !realvpc['id'].nil? and !realvpc['id'].empty? + # Stupid kludge for Google cloud_ids which are sometimes URLs and + # sometimes not. Requirements are inconsistent from scenario to + # scenario. + name = name + "-" + realvpc['id'].gsub(/.*\//, "") + realvpc['id'] = getTail("id", value: realvpc['id'], prettyname: "Admin Firewall Ruleset #{name} Target VPC", cloudtype: "AWS::EC2::VPC::Id") if realvpc["id"].is_a?(String) + elsif !realvpc['name'].nil? + name = name + "-" + realvpc['name'] + end + end + + acl = {"name" => name, "rules" => rules, "vpc" => realvpc, "cloud" => cloud, "admin" => true, "credentials" => credentials } acl.delete("vpc") if !acl["vpc"] - acl["region"] = region if !region.nil? and !region.empty? + if !resclass.isGlobal? and !region.nil? and !region.empty? + acl["region"] = region + end @admin_firewall_rules << acl if !@admin_firewall_rules.include?(acl) return {"type" => "firewall_rule", "name" => name} end @@ -1468,41 +1933,75 @@ def self.include(file, binding = nil, param_pass = false) # (see #include) def include(file) - MU::Config.include(file, get_binding, param_pass = @param_pass) + MU::Config.include(file, get_binding(@@tails.keys.sort), param_pass = @param_pass) + end + + @@bindings = {} + # Keep a cache of bindings we've created as sandbox contexts for ERB + # processing, so we don't keep reloading the entire Mu library inside new + # ones. + def self.global_bindings + @@bindings end # Namespace magic to pass to ERB's result method. - def get_binding - binding + def get_binding(keyset) +# return MU::Config.global_bindings[keyset] if MU::Config.global_bindings[keyset] + MU::Config.global_bindings[keyset] = binding + MU::Config.global_bindings[keyset] end - def self.set_defaults(conf_chunk = config, schema_chunk = schema, depth = 0, siblings = nil) + def applySchemaDefaults(conf_chunk = config, schema_chunk = schema, depth = 0, siblings = nil, type: nil) return if schema_chunk.nil? if conf_chunk != nil and schema_chunk["properties"].kind_of?(Hash) and conf_chunk.is_a?(Hash) + if schema_chunk["properties"]["creation_style"].nil? or schema_chunk["properties"]["creation_style"] != "existing" schema_chunk["properties"].each_pair { |key, subschema| - new_val = self.set_defaults(conf_chunk[key], subschema, depth+1, conf_chunk) - conf_chunk[key] = new_val if new_val != nil + shortclass = if conf_chunk[key] + shortclass, cfg_name, cfg_plural, classname = MU::Cloud.getResourceNames(key) + shortclass + else + nil + end + + new_val = applySchemaDefaults(conf_chunk[key], subschema, depth+1, conf_chunk, type: shortclass).dup + + conf_chunk[key] = Marshal.load(Marshal.dump(new_val)) if !new_val.nil? } end elsif schema_chunk["type"] == "array" and conf_chunk.kind_of?(Array) conf_chunk.map! { |item| - self.set_defaults(item, schema_chunk["items"], depth+1, conf_chunk) + # If we're working on a resource type, go get implementation-specific + # schema information so that we set those defaults correctly. + realschema = if type and schema_chunk["items"] and schema_chunk["items"]["properties"] and item["cloud"] + + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(item["cloud"]).const_get(type) + toplevel_required, cloudschema = cloudclass.schema(self) + + newschema = schema_chunk["items"].dup + newschema["properties"].merge!(cloudschema) + newschema + else + schema_chunk["items"].dup + end + + applySchemaDefaults(item, realschema, depth+1, conf_chunk, type: type).dup } else if conf_chunk.nil? and !schema_chunk["default_if"].nil? and !siblings.nil? schema_chunk["default_if"].each { |cond| if siblings[cond["key_is"]] == cond["value_is"] - return cond["set"] + return Marshal.load(Marshal.dump(cond["set"])) end } end if conf_chunk.nil? and schema_chunk["default"] != nil - return schema_chunk["default"] + return Marshal.load(Marshal.dump(schema_chunk["default"])) end end + return conf_chunk end @@ -1512,48 +2011,46 @@ def self.set_defaults(conf_chunk = config, schema_chunk = schema, depth = 0, sib def self.check_dependencies(config) ok = true - config.each { |type| - if type.instance_of?(Array) - type.each { |container| - if container.instance_of?(Array) - container.each { |resource| - if resource.kind_of?(Hash) and resource["dependencies"] != nil - append = [] - delete = [] - resource["dependencies"].each { |dependency| - collection = dependency["type"]+"s" - found = false - names_seen = [] - if config[collection] != nil - config[collection].each { |service| - names_seen << service["name"].to_s - found = true if service["name"].to_s == dependency["name"].to_s - if service["virtual_name"] - names_seen << service["virtual_name"].to_s - found = true if service["virtual_name"].to_s == dependency["name"].to_s - append_me = dependency.dup - append_me['name'] = service['name'] - append << append_me - delete << dependency - end - } - end - if !found - MU.log "Missing dependency: #{type[0]}{#{resource['name']}} needs #{collection}{#{dependency['name']}}", MU::ERR, details: names_seen - ok = false + config.each_pair { |type, values| + if values.instance_of?(Array) + values.each { |resource| + if resource.kind_of?(Hash) and !resource["dependencies"].nil? + append = [] + delete = [] + resource["dependencies"].each { |dependency| + shortclass, cfg_name, cfg_plural, classname = MU::Cloud.getResourceNames(dependency["type"]) + found = false + names_seen = [] + if !config[cfg_plural].nil? + config[cfg_plural].each { |service| + names_seen << service["name"].to_s + found = true if service["name"].to_s == dependency["name"].to_s + if service["virtual_name"] + names_seen << service["virtual_name"].to_s + if service["virtual_name"].to_s == dependency["name"].to_s + found = true + append_me = dependency.dup + append_me['name'] = service['name'] + append << append_me + delete << dependency + end end } - if append.size > 0 - append.uniq! - resource["dependencies"].concat(append) - end - if delete.size > 0 - delete.each { |delete_me| - resource["dependencies"].delete(delete_me) - } - end + end + if !found + MU.log "Missing dependency: #{type}{#{resource['name']}} needs #{cfg_name}{#{dependency['name']}}", MU::ERR, details: names_seen + ok = false end } + if append.size > 0 + append.uniq! + resource["dependencies"].concat(append) + end + if delete.size > 0 + delete.each { |delete_me| + resource["dependencies"].delete(delete_me) + } + end end } end @@ -1567,7 +2064,7 @@ def self.check_dependencies(config) def self.check_vault_refs(server) ok = true server['vault_access'] = [] if server['vault_access'].nil? - server['groomer'] ||= "Chef" + server['groomer'] ||= self.defaultGroomer groomclass = MU::Groomer.loadGroomer(server['groomer']) begin @@ -1622,23 +2119,39 @@ def self.check_vault_refs(server) # Given a bare hash describing a resource, insert default values which can - # be inherited from the current live parent configuration. + # be inherited from its parent or from the root of the BoK. # @param kitten [Hash]: A resource descriptor # @param type [String]: The type of resource this is ("servers" etc) - def inheritDefaults(kitten, type) + def applyInheritedDefaults(kitten, type) + kitten['cloud'] ||= @config['cloud'] kitten['cloud'] ||= MU::Config.defaultCloud + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(kitten['cloud']) shortclass, cfg_name, cfg_plural, classname = MU::Cloud.getResourceNames(type) resclass = Object.const_get("MU").const_get("Cloud").const_get(kitten['cloud']).const_get(shortclass) - schema_fields = ["us_only", "scrub_mu_isms", "credentials"] + schema_fields = ["us_only", "scrub_mu_isms", "credentials", "billing_acct"] if !resclass.isGlobal? + kitten['region'] ||= @config['region'] + kitten['region'] ||= cloudclass.myRegion(kitten['credentials']) schema_fields << "region" end + kitten['credentials'] ||= @config['credentials'] + kitten['credentials'] ||= cloudclass.credConfig(name_only: true) + + kitten['us_only'] ||= @config['us_only'] + kitten['us_only'] ||= false + + kitten['scrub_mu_isms'] ||= @config['scrub_mu_isms'] + kitten['scrub_mu_isms'] ||= false + if kitten['cloud'] == "Google" - kitten["project"] ||= MU::Cloud::Google.defaultProject(kitten['credentials']) - schema_fields << "project" +# TODO this should be cloud-generic (handle AWS accounts, Azure subscriptions) + if resclass.canLiveIn.include?(:Habitat) + kitten["project"] ||= MU::Cloud::Google.defaultProject(kitten['credentials']) + schema_fields << "project" + end if kitten['region'].nil? and !kitten['#MU_CLOUDCLASS'].nil? and !resclass.isGlobal? and ![MU::Cloud::VPC, MU::Cloud::FirewallRule].include?(kitten['#MU_CLOUDCLASS']) @@ -1647,21 +2160,15 @@ def inheritDefaults(kitten, type) end kitten['region'] ||= MU::Cloud::Google.myRegion end - elsif kitten["cloud"] == "AWS" and !resclass.isGlobal? + elsif kitten["cloud"] == "AWS" and !resclass.isGlobal? and !kitten['region'] if MU::Cloud::AWS.myRegion.nil? raise ValidationError, "AWS resource declared without a region, but no default AWS region found" end kitten['region'] ||= MU::Cloud::AWS.myRegion end - kitten['us_only'] ||= @config['us_only'] - kitten['us_only'] ||= false - kitten['scrub_mu_isms'] ||= @config['scrub_mu_isms'] - kitten['scrub_mu_isms'] ||= false - - kitten['credentials'] ||= @config['credentials'] - kitten['credentials'] ||= cloudclass.credConfig(name_only: true) + kitten['billing_acct'] ||= @config['billing_acct'] if @config['billing_acct'] kitten["dependencies"] ||= [] @@ -1677,7 +2184,6 @@ def inheritDefaults(kitten, type) def validate(config = @config) ok = true - plain_cfg = MU::Config.manxify(Marshal.load(Marshal.dump(config))) count = 0 @kittens ||= {} @@ -1687,7 +2193,7 @@ def validate(config = @config) @kittens[type] = config[type] @kittens[type] ||= [] @kittens[type].each { |k| - inheritDefaults(k, type) + applyInheritedDefaults(k, type) } count = count + @kittens[type].size } @@ -1708,6 +2214,12 @@ def validate(config = @config) acl = resolveIntraStackFirewallRefs(acl) } + # VPCs do complex things in their cloud-layer validation that other + # resources tend to need, like subnet allocation, so hit them early. + @kittens["vpcs"].each { |vpc| + ok = false if !insertKitten(vpc, "vpcs") + } + # Make sure validation has been called for all on-the-fly generated # resources. validated_something_new = false @@ -1715,9 +2227,10 @@ def validate(config = @config) validated_something_new = false types.each { |type| @kittens[type].each { |descriptor| - if !descriptor["#MU_VALIDATED"] + if !descriptor["#MU_VALIDATION_ATTEMPTED"] validated_something_new = true ok = false if !insertKitten(descriptor, type) + descriptor["#MU_VALIDATION_ATTEMPTED"] = true end } } @@ -1798,10 +2311,131 @@ def validate(config = @config) # end end + # Emit our mu.yaml schema in a format that YARD can comprehend and turn into + # documentation. + def self.printMuYamlSchema(muyaml_rb, class_hierarchy, schema, in_array = false, required = false, prefix: nil) + return if schema.nil? + if schema["subtree"] + printme = Array.new + # order sub-elements by whether they're required, so we can use YARD's + # grouping tags on them + have_required = schema["subtree"].keys.any? { |k| schema["subtree"][k]["required"] } + prop_list = schema["subtree"].keys.sort { |a, b| + if schema["subtree"][a]["required"] and !schema["subtree"][b]["required"] + -1 + elsif !schema["subtree"][a]["required"] and schema["subtree"][b]["required"] + 1 + else + a <=> b + end + } + + req = false + printme << "# @!group Optional parameters" if !have_required + prop_list.each { |name| + prop = schema["subtree"][name] + if prop["required"] + printme << "# @!group Required parameters" if !req + req = true + else + if req + printme << "# @!endgroup" + printme << "# @!group Optional parameters" + end + req = false + end + + printme << self.printMuYamlSchema(muyaml_rb, class_hierarchy+ [name], prop, false, req) + } + printme << "# @!endgroup" - # Emit our Basket of Kittesn schema in a format that YARD can comprehend + desc = (schema['desc'] || schema['title']) + + tabs = 1 + class_hierarchy.each { |classname| + if classname == class_hierarchy.last and desc + muyaml_rb.puts ["\t"].cycle(tabs).to_a.join('') + "# #{desc}\n" + end + muyaml_rb.puts ["\t"].cycle(tabs).to_a.join('') + "class #{classname}" + tabs = tabs + 1 + } + printme.each { |lines| + if !lines.nil? and lines.is_a?(String) + lines.lines.each { |line| + muyaml_rb.puts ["\t"].cycle(tabs).to_a.join('') + line + } + end + } + + class_hierarchy.each { |classname| + tabs = tabs - 1 + muyaml_rb.puts ["\t"].cycle(tabs).to_a.join('') + "end" + } + + # And now that we've dealt with our children, pass our own rendered + # commentary back up to our caller. + name = class_hierarchy.last + if in_array + type = "Array<#{class_hierarchy.join("::")}>" + else + type = class_hierarchy.join("::") + end + + docstring = "\n" + docstring = docstring + "# **REQUIRED**\n" if required +# docstring = docstring + "# **"+schema["prefix"]+"**\n" if schema["prefix"] + docstring = docstring + "# #{desc.gsub(/\n/, "\n#")}\n" if desc + docstring = docstring + "#\n" + docstring = docstring + "# @return [#{type}]\n" + docstring = docstring + "# @see #{class_hierarchy.join("::")}\n" + docstring = docstring + "attr_accessor :#{name}" + return docstring + + else + in_array = schema["array"] + name = class_hierarchy.last + type = if schema['boolean'] + "Boolean" + else + "String" + end + if in_array + type = "Array<#{type}>" + end + docstring = "\n" + + prefixes = [] + prefixes << "# **REQUIRED**" if schema["required"] and schema['default'].nil? +# prefixes << "# **"+schema["prefix"]+"**" if schema["prefix"] + prefixes << "# **Default: `#{schema['default']}`**" if !schema['default'].nil? + if !schema['pattern'].nil? + # XXX unquoted regex chars confuse the hell out of YARD. How do we + # quote {}[] etc in YARD-speak? + prefixes << "# **Must match pattern `#{schema['pattern'].to_s.gsub(/\n/, "\n#")}`**" + end + + desc = (schema['desc'] || schema['title']) + if prefixes.size > 0 + docstring += prefixes.join(",\n") + if desc and desc.size > 1 + docstring += " - " + end + docstring += "\n" + end + + docstring = docstring + "# #{desc.gsub(/\n/, "\n#")}\n" if !desc.nil? + docstring = docstring + "#\n" + docstring = docstring + "# @return [#{type}]\n" + docstring = docstring + "attr_accessor :#{name}" + + return docstring + end + + end + + # Emit our Basket of Kittens schema in a format that YARD can comprehend # and turn into documentation. - def self.printSchema(dummy_kitten_class, class_hierarchy, schema, in_array = false, required = false, prefix: nil) + def self.printSchema(kitten_rb, class_hierarchy, schema, in_array = false, required = false, prefix: nil) return if schema.nil? if schema["type"] == "object" printme = Array.new @@ -1830,7 +2464,7 @@ def self.printSchema(dummy_kitten_class, class_hierarchy, schema, in_array = fal req = false end - printme << self.printSchema(dummy_kitten_class, class_hierarchy+ [name], prop, false, req, prefix: schema["prefix"]) + printme << self.printSchema(kitten_rb, class_hierarchy+ [name], prop, false, req, prefix: schema["prefix"]) } printme << "# @!endgroup" end @@ -1838,22 +2472,22 @@ def self.printSchema(dummy_kitten_class, class_hierarchy, schema, in_array = fal tabs = 1 class_hierarchy.each { |classname| if classname == class_hierarchy.last and !schema['description'].nil? - dummy_kitten_class.puts ["\t"].cycle(tabs).to_a.join('') + "# #{schema['description']}\n" + kitten_rb.puts ["\t"].cycle(tabs).to_a.join('') + "# #{schema['description']}\n" end - dummy_kitten_class.puts ["\t"].cycle(tabs).to_a.join('') + "class #{classname}" + kitten_rb.puts ["\t"].cycle(tabs).to_a.join('') + "class #{classname}" tabs = tabs + 1 } printme.each { |lines| if !lines.nil? and lines.is_a?(String) lines.lines.each { |line| - dummy_kitten_class.puts ["\t"].cycle(tabs).to_a.join('') + line + kitten_rb.puts ["\t"].cycle(tabs).to_a.join('') + line } end } class_hierarchy.each { |classname| tabs = tabs - 1 - dummy_kitten_class.puts ["\t"].cycle(tabs).to_a.join('') + "end" + kitten_rb.puts ["\t"].cycle(tabs).to_a.join('') + "end" } # And now that we've dealt with our children, pass our own rendered @@ -1876,7 +2510,7 @@ def self.printSchema(dummy_kitten_class, class_hierarchy, schema, in_array = fal return docstring elsif schema["type"] == "array" - return self.printSchema(dummy_kitten_class, class_hierarchy, schema['items'], true, required, prefix: prefix) + return self.printSchema(kitten_rb, class_hierarchy, schema['items'], true, required, prefix: prefix) else name = class_hierarchy.last if schema['type'].nil? @@ -1918,7 +2552,6 @@ def self.printSchema(dummy_kitten_class, class_hierarchy, schema, in_array = fal return docstring end - return nil end def self.dependencies_primitive @@ -1969,6 +2602,57 @@ def self.notification_email end end + # Load and validate the schema for an individual resource class, optionally + # merging cloud-specific schema components. + # @param type [String]: The resource type to load + # @param cloud [String]: A specific cloud, whose implementation's schema of this resource we will merge + # @return [Hash] + def self.loadResourceSchema(type, cloud: nil) + valid = true + shortclass, cfg_name, cfg_plural, classname = MU::Cloud.getResourceNames(type) + schemaclass = Object.const_get("MU").const_get("Config").const_get(shortclass) + + [:schema, :validate].each { |method| + if !schemaclass.respond_to?(method) + MU.log "MU::Config::#{type}.#{method.to_s} doesn't seem to be implemented", MU::ERR + return [nil, false] if method == :schema + valid = false + end + } + + schema = schemaclass.schema.dup + + schema["properties"]["virtual_name"] = { + "description" => "Internal use.", + "type" => "string" + } + schema["properties"]["dependencies"] = MU::Config.dependencies_primitive + schema["properties"]["cloud"] = MU::Config.cloud_primitive + schema["properties"]["credentials"] = MU::Config.credentials_primitive + schema["title"] = type.to_s + + if cloud + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud).const_get(shortclass) + + if cloudclass.respond_to?(:schema) + reqd, cloudschema = cloudclass.schema + cloudschema.each { |key, cfg| + if schema["properties"][key] + schemaMerge(schema["properties"][key], cfg, cloud) + else + schema["properties"][key] = cfg.dup + end + } + else + MU.log "MU::Cloud::#{cloud}::#{type}.#{method.to_s} doesn't seem to be implemented", MU::ERR + valid = false + end + + end + + return [schema, valid] + end + @@schema = { "$schema" => "http://json-schema.org/draft-04/schema#", "title" => "MU Application", @@ -1986,7 +2670,11 @@ def self.notification_email }, "project" => { "type" => "string", - "description" => "GOOGLE: The project into which to deploy resources" + "description" => "**GOOGLE ONLY**: The project into which to deploy resources" + }, + "billing_acct" => { + "type" => "string", + "description" => "**GOOGLE ONLY**: Billing account ID to associate with a newly-created Google Project. If not specified, will attempt to locate a billing account associated with the default project for our credentials.", }, "region" => MU::Config.region_primitive, "credentials" => MU::Config.credentials_primitive, @@ -2084,28 +2772,16 @@ def self.notification_email end } + MU::Cloud.resource_types.each_pair { |type, cfg| begin - schemaclass = Object.const_get("MU").const_get("Config").const_get(type) - [:schema, :validate].each { |method| - if !schemaclass.respond_to?(method) - MU.log "MU::Config::#{type}.#{method.to_s} doesn't seem to be implemented", MU::ERR - failed << type - end - } + schema, valid = loadResourceSchema(type) + failed << type if !valid next if failed.include?(type) @@schema["properties"][cfg[:cfg_plural]] = { "type" => "array", - "items" => schemaclass.schema - } - @@schema["properties"][cfg[:cfg_plural]]["items"]["properties"]["virtual_name"] = { - "description" => "Internal use.", - "type" => "string" + "items" => schema } - @@schema["properties"][cfg[:cfg_plural]]["items"]["properties"]["dependencies"] = MU::Config.dependencies_primitive - @@schema["properties"][cfg[:cfg_plural]]["items"]["properties"]["cloud"] = MU::Config.cloud_primitive - @@schema["properties"][cfg[:cfg_plural]]["items"]["properties"]["credentials"] = MU::Config.credentials_primitive - @@schema["properties"][cfg[:cfg_plural]]["items"]["title"] = type.to_s rescue NameError => e failed << type MU.log "Error loading #{type} schema from mu/config/#{cfg[:cfg_name]}", MU::ERR, details: "\t"+e.inspect+"\n\t"+e.backtrace[0] diff --git a/modules/mu/config/alarm.rb b/modules/mu/config/alarm.rb index d51396bbd..a5d1f0552 100644 --- a/modules/mu/config/alarm.rb +++ b/modules/mu/config/alarm.rb @@ -248,6 +248,7 @@ def self.validate(alarm, configurator) # ok = false #end end + alarm["notification_endpoint"] ||= MU.muCfg['mu_admin_email'] if alarm["dimensions"] alarm["dimensions"].each{ |dimension| diff --git a/modules/mu/config/bucket.rb b/modules/mu/config/bucket.rb index 4875116a4..97970ac11 100644 --- a/modules/mu/config/bucket.rb +++ b/modules/mu/config/bucket.rb @@ -52,7 +52,7 @@ def self.schema }, "policies" => { "type" => "array", - "items" => MU::Config::Role.policy_primitive(subobjects: true, grant_to: true, permissions_optional: true) + "items" => MU::Config::Role.policy_primitive(subobjects: true, grant_to: true, permissions_optional: true, targets_optional: true) } } } diff --git a/modules/mu/config/cache_cluster.rb b/modules/mu/config/cache_cluster.rb index 3f21adbda..1ac57bc10 100644 --- a/modules/mu/config/cache_cluster.rb +++ b/modules/mu/config/cache_cluster.rb @@ -163,7 +163,7 @@ def self.validate(cluster, configurator) end cluster["multi_az"] = true if cluster["node_count"] > 1 - if !cluster['scrub_mu_isms'] + if !cluster['scrub_mu_isms'] and cluster["cloud"] != "Azure" cluster['dependencies'] << configurator.adminFirewallRuleset(vpc: cluster['vpc'], region: cluster['region'], cloud: cluster['cloud'], credentials: cluster['credentials']) end diff --git a/modules/mu/config/cache_cluster.yml b/modules/mu/config/cache_cluster.yml index c10d2797b..3b716c8d8 100644 --- a/modules/mu/config/cache_cluster.yml +++ b/modules/mu/config/cache_cluster.yml @@ -1,22 +1,18 @@ <% if $complexity == "complex" %> name: redis -credentials: egtprod engine: redis creation_style: new size: cache.t2.medium name: memcache -credentials: egtprod creation_style: new engine: memcached size: cache.t2.medium <% else %> name: redis -credentials: egtprod engine: redis creation_style: new size: cache.t2.medium name: memcache -credentials: egtprod creation_style: new engine: memcached size: cache.t2.medium diff --git a/modules/mu/config/container_cluster.rb b/modules/mu/config/container_cluster.rb index 4468ae08e..91c53d9ea 100644 --- a/modules/mu/config/container_cluster.rb +++ b/modules/mu/config/container_cluster.rb @@ -23,8 +23,7 @@ def self.schema base = { "type" => "object", "description" => "Create a cluster of container hosts.", - "required" => ["name", "cloud", "instance_type", "instance_count"], - "additionalProperties" => false, + "required" => ["name", "cloud", "instance_type"], "properties" => { "name" => { "type" => "string" }, "region" => MU::Config.region_primitive, @@ -35,18 +34,26 @@ def self.schema "type" => "integer", "default" => 2 }, + "min_size" => { + "type" => "integer", + "description" => "Enable worker cluster scaling and set the minimum number of workers to this value. This value is ignored for platforms which abstract scaling activity, such as AWS Fargate." + }, + "max_size" => { + "type" => "integer", + "description" => "Enable worker cluster scaling and set the maximum number of workers to this value. This value is ignored for platforms which abstract scaling activity, such as AWS Fargate." + }, "kubernetes" => { "type" => "object", - "description" => "Options for Kubernetes, specific to EKS or GKE", + "description" => "Kubernetes-specific options", "properties" => { "version" => { "type" => "string", - "default" => "1.10", + "default" => "1.13", "description" => "Version of Kubernetes control plane to deploy", }, "max_pods" => { "type" => "integer", - "default" => 5, + "default" => 30, "description" => "Maximum number of pods that can be deployed on any given worker node", } } @@ -58,10 +65,6 @@ def self.schema "description" => "Optional Kubernetes-specific resource descriptors to run with kubectl create|replace when grooming this cluster. See https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/#understanding-kubernetes-objects" } }, - "flavor" => { - "type" => "string", - "description" => "Container clusters in Amazon can be ECS, EKS, or Fargate; Google supports GKE only" - }, "platform" => { "type" => "string", "default" => "linux", @@ -95,6 +98,12 @@ def self.schema # @return [Boolean]: True if validation succeeded, False otherwise def self.validate(cluster, configurator) ok = true + + if cluster["max_size"] or cluster["min_size"] + cluster["max_size"] ||= [cluster["instance_count"], cluster["min_size"]].reject { |c| c.nil? }.max + cluster["min_size"] ||= [cluster["instance_count"], cluster["min_size"]].reject { |c| c.nil? }.min + end + ok end diff --git a/modules/mu/config/container_cluster.yml b/modules/mu/config/container_cluster.yml index 5041ddc1c..922ecd3cf 100644 --- a/modules/mu/config/container_cluster.yml +++ b/modules/mu/config/container_cluster.yml @@ -5,7 +5,6 @@ instance_type: t2.medium instance_count: 4 kubernetes: max_pods: 10 -instance_subnet_pref: all_public platform: centos # This stanza is optional; normal deployments to EKS might be done with kubectl # or through a CI tool such as GitLab diff --git a/modules/mu/config/database.rb b/modules/mu/config/database.rb index 491ddcae0..35f2e2ed1 100644 --- a/modules/mu/config/database.rb +++ b/modules/mu/config/database.rb @@ -42,13 +42,13 @@ def self.schema "tags" => MU::Config.tags_primitive, "optional_tags" => MU::Config.optional_tags_primitive, "alarms" => MU::Config::Alarm.inline, - "engine_version" => {"type" => "string"}, "add_firewall_rules" => MU::Config::FirewallRule.reference, "read_replica_of" => reference, "ingress_rules" => { "type" => "array", "items" => MU::Config::FirewallRule.ruleschema }, + "engine_version" => {"type" => "string"}, "engine" => { "enum" => ["mysql", "postgres", "oracle-se1", "oracle-se2", "oracle-se", "oracle-ee", "sqlserver-ee", "sqlserver-se", "sqlserver-ex", "sqlserver-web", "aurora", "mariadb"], "type" => "string" @@ -169,13 +169,7 @@ def self.schema "cluster_node_count" => { "type" => "integer", "description" => "The number of database instances to add to a database cluster. This only applies to aurora", - "default_if" => [ - { - "key_is" => "engine", - "value_is" => "aurora", - "set" => 1 - } - ] + "default" => 2 }, "create_cluster" => { "type" => "boolean", @@ -188,17 +182,6 @@ def self.schema } ] }, - "parameter_group_family" => { - "type" => "String", - "enum" => [ - "postgres9.6", "postgres9.5", "postgres9.4", "postgres9.3", - "mysql5.1", "mysql5.5", "mysql5.6", "mysql5.7", - "oracle-ee-11.2", "oracle-ee-12.1", "oracle-se-11.2", "oracle-se-12.1", "oracle-se1-11.2", "oracle-se1-12.1", - "sqlserver-ee-10.5", "sqlserver-ee-11.0", "sqlserver-ee-12.0", "sqlserver-ex-10.5", "sqlserver-ex-11.0", "sqlserver-ex-12.0", "sqlserver-se-10.5", "sqlserver-se-11.0", "sqlserver-se-12.0", "sqlserver-web-10.5", "sqlserver-web-11.0", "sqlserver-web-12.0", - "aurora5.6", "mariadb-10.0", "mariadb-10.1" - ], - "description" => "The database family to create the DB Parameter Group for. The family type must be the same type as the database major version - eg if you set engine_version to 9.4.4 the db_family must be set to postgres9.4." - }, "auth_vault" => { "type" => "object", "additionalProperties" => false, @@ -327,9 +310,9 @@ def self.validate(db, configurator) if !db["vpc"].nil? if db["vpc"]["subnet_pref"] and !db["vpc"]["subnets"] - if db["vpc"]["subnet_pref"] = "public" + if db["vpc"]["subnet_pref"] == "public" db["vpc"]["subnet_pref"] = "all_public" - elsif db["vpc"]["subnet_pref"] = "private" + elsif db["vpc"]["subnet_pref"] == "private" db["vpc"]["subnet_pref"] = "all_private" elsif %w{all any}.include? db["vpc"]["subnet_pref"] MU.log "subnet_pref #{db["vpc"]["subnet_pref"]} is not supported for database instance.", MU::ERR @@ -452,8 +435,8 @@ def self.validate(db, configurator) end db['dependencies'].uniq! - read_replicas.each { |replica| - ok = false if !configurator.insertKitten(replica, "databases") + read_replicas.each { |new_replica| + ok = false if !configurator.insertKitten(new_replica, "databases") } cluster_nodes.each { |member| ok = false if !configurator.insertKitten(member, "databases") diff --git a/modules/mu/config/database.yml b/modules/mu/config/database.yml index be8fd3542..cc256ed41 100644 --- a/modules/mu/config/database.yml +++ b/modules/mu/config/database.yml @@ -11,14 +11,17 @@ auto_minor_version_upgrade: false backup_retention_period: 10 cluster_node_count: 2 create_cluster: true +vpc: + vpc_name: <%= vpc_name %> create_read_replica: true master_user: Bob multi_az_on_create: true -region: us-west-2 <% else %> # IF NOT COMPLEX THEN ASSUME SIMPLE name: database-simple +vpc: + vpc_name: <%= vpc_name %> size: <%= db_size %> engine: mariadb storage: 5 diff --git a/modules/mu/config/firewall_rule.rb b/modules/mu/config/firewall_rule.rb index 5ccfb4313..06ec718fb 100644 --- a/modules/mu/config/firewall_rule.rb +++ b/modules/mu/config/firewall_rule.rb @@ -48,8 +48,8 @@ def self.schema "default" => false }, "rules" => { - "type" => "array", - "items" => ruleschema + "type" => "array", + "items" => ruleschema } } } @@ -61,7 +61,7 @@ def self.ruleschema { "type" => "object", "description" => "Network ingress and/or egress rules.", - "additionalProperties" => false, +# "additionalProperties" => false, # inline ingress_rules can have cloud-specific attributes, and this trips those up "properties" => { "port_range" => {"type" => "string"}, "port" => {"type" => "integer"}, @@ -71,13 +71,16 @@ def self.ruleschema "type" => "string" }, "ingress" => { - "type" => "boolean", - "default" => true + "type" => "boolean" }, "egress" => { "type" => "boolean", "default" => false }, + "comment" => { + "type" => "string", + "description" => "String description of this firewall rule, where supported" + }, "hosts" => { "type" => "array", "items" => MU::Config::CIDR_PRIMITIVE @@ -89,18 +92,13 @@ def self.ruleschema # Schema block for other resources to use when referencing a sibling FirewallRule # @return [Hash] def self.reference + schema_aliases = [ + { "rule_id" => "id" }, + { "rule_name" => "name" } + ] { "type" => "array", - "items" => { - "type" => "object", - "additionalProperties" => false, - "description" => "Apply one or more network rulesets, defined in this stack or pre-existing, to this resource. Note that if you add a pre-existing ACL to your resource, they must be compatible (e.g. if using VPCs, they must reside in the same VPC).", - "minProperties" => 1, - "properties" => { - "rule_id" => {"type" => "string"}, - "rule_name" => {"type" => "string"} - } - } + "items" => MU::Config::Ref.schema(schema_aliases, type: "firewall_rules") } end diff --git a/modules/mu/config/folder.rb b/modules/mu/config/folder.rb index 4aa95f68d..86ebe6a90 100644 --- a/modules/mu/config/folder.rb +++ b/modules/mu/config/folder.rb @@ -34,27 +34,28 @@ def self.schema # Chunk of schema to reference a folder/OU, here to be embedded # into the schemas of other resources. def self.reference - { - "type" => "object", - "description" => "Deploy into or connect with resources in a specific account/project", - "minProperties" => 1, - "additionalProperties" => false, - "properties" => { - "id" => { - "type" => "string", - "description" => "Discover this folder/OU by looking by its cloud provider identifier " - }, - "name" => { - "type" => "string", - "description" => "Discover this folder/OU by Mu-internal name; typically the shorthand 'name' field of an Folder object declared elsewhere in the deploy, or in another deploy that's being referenced with 'deploy_id'." - }, - "cloud" => MU::Config.cloud_primitive, - "deploy_id" => { - "type" => "string", - "description" => "Search for this folder in an existing Mu deploy; specify a Mu deploy id (e.g. DEMO-DEV-2014111400-NG)." - } - } - } +# { +# "type" => "object", +# "description" => "Deploy into or connect with resources in a specific account/project", +# "minProperties" => 1, +# "additionalProperties" => false, +# "properties" => { +# "id" => { +# "type" => "string", +# "description" => "Discover this folder/OU by looking by its cloud provider identifier " +# }, +# "name" => { +# "type" => "string", +# "description" => "Discover this folder/OU by Mu-internal name; typically the shorthand 'name' field of an Folder object declared elsewhere in the deploy, or in another deploy that's being referenced with 'deploy_id'." +# }, +# "cloud" => MU::Config.cloud_primitive, +# "deploy_id" => { +# "type" => "string", +# "description" => "Search for this folder in an existing Mu deploy; specify a Mu deploy id (e.g. DEMO-DEV-2014111400-NG)." +# } +# } +# } + MU::Config::Ref.schema(type: "folders") end # Generic pre-processing of {MU::Config::BasketofKittens::folder}, bare and unvalidated. diff --git a/modules/mu/config/habitat.rb b/modules/mu/config/habitat.rb index 39194ac4c..fc4100f7f 100644 --- a/modules/mu/config/habitat.rb +++ b/modules/mu/config/habitat.rb @@ -34,27 +34,28 @@ def self.schema # Chunk of schema to reference an account/project, here to be embedded # into the schemas of other resources. def self.reference - { - "type" => "object", - "description" => "Deploy into or connect with resources in a specific habitat (AWS account, GCP project, etc)", - "minProperties" => 1, - "additionalProperties" => false, - "properties" => { - "id" => { - "type" => "string", - "description" => "Discover this habitat by looking for this cloud provider identifier, such as 836541910896 (an AWS account number) or my-project-196124 (a Google Cloud project id)" - }, - "name" => { - "type" => "string", - "description" => "Discover this habitat by Mu-internal name; typically the shorthand 'name' field of a Habitat object declared elsewhere in the deploy, or in another deploy that's being referenced with 'deploy_id'." - }, - "cloud" => MU::Config.cloud_primitive, - "deploy_id" => { - "type" => "string", - "description" => "Search for this Habitat in an existing Mu deploy by Mu deploy id (e.g. DEMO-DEV-2014111400-NG)." - } - } - } +# { +# "type" => "object", +# "description" => "Deploy into or connect with resources in a specific habitat (AWS account, GCP project, etc)", +# "minProperties" => 1, +# "additionalProperties" => false, +# "properties" => { +# "id" => { +# "type" => "string", +# "description" => "Discover this habitat by looking for this cloud provider identifier, such as 836541910896 (an AWS account number) or my-project-196124 (a Google Cloud project id)" +# }, +# "name" => { +# "type" => "string", +# "description" => "Discover this habitat by Mu-internal name; typically the shorthand 'name' field of a Habitat object declared elsewhere in the deploy, or in another deploy that's being referenced with 'deploy_id'." +# }, +# "cloud" => MU::Config.cloud_primitive, +# "deploy_id" => { +# "type" => "string", +# "description" => "Search for this Habitat in an existing Mu deploy by Mu deploy id (e.g. DEMO-DEV-2014111400-NG)." +# } +# } +# } + MU::Config::Ref.schema(type: "habitats") end # Generic pre-processing of {MU::Config::BasketofKittens::habitat}, bare and unvalidated. diff --git a/modules/mu/config/loadbalancer.rb b/modules/mu/config/loadbalancer.rb index a373ecc19..220f1cbf8 100644 --- a/modules/mu/config/loadbalancer.rb +++ b/modules/mu/config/loadbalancer.rb @@ -103,8 +103,8 @@ def self.schema }, "alarms" => MU::Config::Alarm.inline, "ingress_rules" => { - "type" => "array", - "items" => MU::Config::FirewallRule.ruleschema + "type" => "array", + "items" => MU::Config::FirewallRule.ruleschema }, "region" => MU::Config.region_primitive, "cross_zone_unstickiness" => { diff --git a/modules/mu/config/role.rb b/modules/mu/config/role.rb index db5d9e00f..c8a22c3af 100644 --- a/modules/mu/config/role.rb +++ b/modules/mu/config/role.rb @@ -45,16 +45,22 @@ def self.schema } end + # Chunk of schema to reference an account/project, here to be embedded + # into the schemas of other resources. + def self.reference + MU::Config::Ref.schema(type: "roles") + end + # A generic, cloud-neutral descriptor for a policy that grants or denies # permissions to some entity over some other entity. # @param subobjects [Boolean]: Whether the returned schema should include a +path+ parameter # @param grant_to [Boolean]: Whether the returned schema should include an explicit +grant_to+ parameter # @return [Hash] - def self.policy_primitive(subobjects: false, grant_to: false, permissions_optional: false) + def self.policy_primitive(subobjects: false, grant_to: false, permissions_optional: false, targets_optional: false) cfg = { "type" => "object", "description" => "Policies which grant or deny permissions.", - "required" => ["name", "targets"], + "required" => ["name"], # "additionalProperties" => false, "properties" => { "name" => { @@ -89,6 +95,9 @@ def self.policy_primitive(subobjects: false, grant_to: false, permissions_option "identifier" => { "type" => "string", "description" => "Either the name of a sibling Mu resource in this stack (used in conjunction with +entity_type+), or the full cloud identifier for a resource, such as an ARN in Amazon Web Services." + }, + "path" => { + "type" => "string", } } } @@ -97,28 +106,17 @@ def self.policy_primitive(subobjects: false, grant_to: false, permissions_option } cfg["required"] << "permissions" if !permissions_optional + cfg["required"] << "targets" if !targets_optional + + schema_aliases = [ + { "identifier" => "id" }, + ] if grant_to cfg["properties"]["grant_to"] = { "type" => "array", "default" => [ { "identifier" => "*" } ], - "items" => { - "type" => "object", - "description" => "Entities to which this policy will grant or deny access.", - "required" => ["identifier"], - "additionalProperties" => false, - "properties" => { - "type" => { - "type" => "string", - "description" => "A Mu resource type, used when referencing a sibling Mu resource in this stack with +identifier+.", - "enum" => MU::Cloud.resource_types.values.map { |t| t[:cfg_name] }.sort - }, - "identifier" => { - "type" => "string", - "description" => "Either the name of a sibling Mu resource in this stack (used in conjunction with +entity_type+), or the full cloud identifier for a resource, such as an Amazon ARN or email-address-formatted Google Cloud username. Wildcards (+*+) are valid if supported by the cloud provider." - } - } - } + "items" => MU::Config::Ref.schema(schema_aliases, desc: "Entities to which this policy will grant or deny access.") } end diff --git a/modules/mu/config/search_domain.yml b/modules/mu/config/search_domain.yml index 1fbc6c84d..299bbc57c 100644 --- a/modules/mu/config/search_domain.yml +++ b/modules/mu/config/search_domain.yml @@ -3,11 +3,12 @@ name: searchdomain-complex instance_type: t2.small.elasticsearch instance_count: 4 -dedicated_masters: 1 +dedicated_masters: 3 master_instance_type: t2.medium.elasticsearch zone_aware: true -ebs_iops: 100 -ebs_type: gp2 +#ebs_iops: 100 # also has value restrictions, eesh +#ebs_size: 35 # this is somehow locked to instance type +#ebs_type: gp2 elasticsearch_version: "6.2" advanced_options: "indices.query.bool.max_clause_count": 512 diff --git a/modules/mu/config/server.rb b/modules/mu/config/server.rb index ca8a2dd28..9afa7db8b 100644 --- a/modules/mu/config/server.rb +++ b/modules/mu/config/server.rb @@ -120,6 +120,10 @@ def self.static_ip_primitive def self.common_properties { "name" => {"type" => "string"}, + "ansible_vars" => { + "type" => "object", + "description" => "When using Ansible as a groomer, this will insert a +vars+ tree into the playbook for this node." + }, "scrub_mu_isms" => { "type" => "boolean", "default" => false, @@ -132,14 +136,23 @@ def self.common_properties "description" => "Bootstrap asynchronously via the Momma Cat daemon instead of during the main deployment process" }, "groomer" => { - "type" => "string", - "default" => MU::Config.defaultGroomer, - "enum" => MU.supportedGroomers + "type" => "string", + "default" => MU::Config.defaultGroomer, + "enum" => MU.supportedGroomers + }, + "groomer_autofetch" => { + "type" => "boolean", + "description" => "For groomer implementations which support automatically fetching roles/recipes/manifests from a public library, such as Ansible Galaxy, this will toggle this behavior on or off.", + "default" => true }, "groom" => { - "type" => "boolean", - "default" => true, - "description" => "Whether to run a host configuration agent, e.g. Chef, when bootstrapping" + "type" => "boolean", + "default" => true, + "description" => "Whether to run a host configuration agent, e.g. Chef, when bootstrapping" + }, + "groomer_variables" => { + "type" => "object", + "description" => "Metadata variables to expose to Groomer clients, under a top-level key named +mu+. Same thing as +application_attributes+, but with a name that makes a modicum of sense." }, "groomer_timeout" => { "type" => "integer", @@ -407,15 +420,14 @@ def self.common_properties "platform" => { "type" => "string", "default" => "linux", - "enum" => ["linux", "windows", "centos", "ubuntu", "centos6", "ubuntu14", "win2k12", "win2k12r2", "win2k16", "centos7", "rhel7", "rhel71", "amazon"], -# XXX change to reflect available keys in mu/defaults/amazon_images.yaml and mu/defaults/google_images.yaml - "description" => "Helps select default AMIs, and enables correct grooming behavior based on operating system type.", + "enum" => MU::Cloud.listPlatforms, + "description" => "Helps select default machine images, and enables correct grooming behavior based on operating system type.", }, "run_list" => { "type" => "array", "items" => { "type" => "string", - "description" => "Chef run list entry, e.g. role[rolename] or recipe[recipename]." + "description" => "A list of +groomer+ recipes/roles/scripts to run, using naming conventions specific to the appropriate grooming layer. In +Chef+, this corresponds to a node's +run_list+ attribute, and entries should be of the form role[rolename] or recipe[recipename]. In +Ansible+, it should be a list of roles (+rolename+), which Mu will use to generate a custom Playbook for the deployment." } }, "ingress_rules" => { @@ -495,11 +507,19 @@ def self.schema "description" => "Create individual server instances.", "properties" => { "dns_records" => MU::Config::DNSZone.records_primitive(need_target: false, default_type: "A", need_zone: true), + "bastion" => { + "type" => "boolean", + "default" => false, + "description" => "Allow this server to be automatically used as a bastion host" + }, + "image_id" => { + "type" => "string", + "description" => "The cloud provider image on which to base this instance. Will use the default appropriate for the +platform+, if not specified." + }, "create_image" => { "type" => "object", "title" => "create_image", "required" => ["image_then_destroy", "image_exclude_storage", "public"], - "additionalProperties" => false, "description" => "Create a reusable image of this server once it is complete.", "properties" => { "public" => { @@ -565,7 +585,7 @@ def self.validate(server, configurator) server['vault_access'] << {"vault" => "splunk", "item" => "admin_user"} ok = false if !MU::Config.check_vault_refs(server) - if !server['scrub_mu_isms'] + if server["cloud"] != "Azure" server['dependencies'] << configurator.adminFirewallRuleset(vpc: server['vpc'], region: server['region'], cloud: server['cloud'], credentials: server['credentials']) end @@ -588,6 +608,16 @@ def self.validate(server, configurator) "name" => configurator.nat_routes[server["vpc"]["subnet_name"]], "phase" => "groom" } + elsif !server["vpc"]["name"].nil? + siblingvpc = configurator.haveLitterMate?(server["vpc"]["name"], "vpcs") + if siblingvpc and siblingvpc['bastion'] and + server['name'] != siblingvpc['bastion'].to_h['name'] + server["dependencies"] << { + "type" => "server", + "name" => siblingvpc['bastion'].to_h['name'], + "phase" => "groom" + } + end end end diff --git a/modules/mu/config/server.yml b/modules/mu/config/server.yml index 92c5c4884..eda6818ec 100644 --- a/modules/mu/config/server.yml +++ b/modules/mu/config/server.yml @@ -1,8 +1,11 @@ <% if $complexity == 'complex' %> name: servercomplex size: <%= instance_type %> -# TODO: BUILD OUT COMPLEX EXAMPLE +vpc: + vpc_name: <%= vpc_name %> <% else %> name: serversimple size: <%= instance_type %> +vpc: + vpc_name: <%= vpc_name %> <% end %> diff --git a/modules/mu/config/server_pool.rb b/modules/mu/config/server_pool.rb index 8c0868512..8a054198f 100644 --- a/modules/mu/config/server_pool.rb +++ b/modules/mu/config/server_pool.rb @@ -180,7 +180,7 @@ def self.validate(pool, configurator) pool['vault_access'] << {"vault" => "splunk", "item" => "admin_user"} ok = false if !MU::Config.check_vault_refs(pool) - if !pool['scrub_mu_isms'] + if !pool['scrub_mu_isms'] and pool["cloud"] != "Azure" pool['dependencies'] << configurator.adminFirewallRuleset(vpc: pool['vpc'], region: pool['region'], cloud: pool['cloud'], credentials: pool['credentials']) end diff --git a/modules/mu/config/server_pool.yml b/modules/mu/config/server_pool.yml index 9af24d0e4..fc3d5cbdc 100644 --- a/modules/mu/config/server_pool.yml +++ b/modules/mu/config/server_pool.yml @@ -1,6 +1,8 @@ <% if $complexity == 'complex' %> name: <%= server_pools_name %> cloud: AWS +vpc: + vpc_name: <%= vpc_name %> alarms: - comparison_operator: "GreaterThanThreshold" metric_name: "HTTPCode_Target_5XX_Count" diff --git a/modules/mu/config/storage_pool.rb b/modules/mu/config/storage_pool.rb index 906904f0d..8e5041ebc 100644 --- a/modules/mu/config/storage_pool.rb +++ b/modules/mu/config/storage_pool.rb @@ -93,7 +93,7 @@ def self.validate(pool, configurator) siblingvpc = configurator.haveLitterMate?(mp["vpc"]["vpc_name"], "vpcs") if !MU::Config::VPC.processReference(mp['vpc'], "storage_pools", - "storagepool '#{pool['name']}'", + pool, configurator, dflt_region: pool['region'], credentials: pool['credentials'], @@ -104,7 +104,7 @@ def self.validate(pool, configurator) else if !MU::Config::VPC.processReference(mp["vpc"], "storage_pools", - "storagepool #{pool['name']}", + pool, configurator, dflt_region: pool['region'], credentials: pool['credentials']) diff --git a/modules/mu/config/user.rb b/modules/mu/config/user.rb index 5a1dd90fb..ef147143d 100644 --- a/modules/mu/config/user.rb +++ b/modules/mu/config/user.rb @@ -41,6 +41,10 @@ def self.schema "description" => "If we attempt to create or associate a user that already exists, simply modify that user in-place and use it, rather than throwing an error. If this flag is set, the user will *not* be deleted on cleanup, nor will we overwrite any existing tags on cloud platforms that support user tagging.", "default" => true }, + "force_password_change" => { + "type" => "boolean", + "description" => "For supported platforms and user types, require the user to reset their password on their next login. Our default behavior is to set this flag when initially creating an account. Setting it explicitly +true+ will set this flag on every subsequent +groom+ of the user, which may not be desired behavior." + }, "create_api_key" => { "type" => "boolean", "default" => false, diff --git a/modules/mu/config/vpc.rb b/modules/mu/config/vpc.rb index 4f09f7a6c..ce0073ecf 100644 --- a/modules/mu/config/vpc.rb +++ b/modules/mu/config/vpc.rb @@ -27,15 +27,21 @@ def self.schema "description" => "Create Virtual Private Clouds with custom public or private subnets.", "properties" => { "name" => {"type" => "string"}, + "habitat" => MU::Config::Habitat.reference, "cloud" => MU::Config.cloud_primitive, "ip_block" => { "type" => "string", "pattern" => MU::Config::CIDR_PATTERN, - "description" => MU::Config::CIDR_DESCRIPTION, - "default" => "10.0.0.0/16" + "description" => MU::Config::CIDR_DESCRIPTION }, "tags" => MU::Config.tags_primitive, "optional_tags" => MU::Config.optional_tags_primitive, + "create_bastion" => { + "type" => "boolean", + "description" => "If we have private subnets and our Mu Master will not be able to route directly to them, create a small instance to serve as an ssh relay.", + "default" => true + }, + "bastion" => MU::Config::Ref.schema(type: "servers", desc: "A reference to a bastion host that can be used to tunnel into private address space in this VPC."), "create_standard_subnets" => { "type" => "boolean", "description" => "If the 'subnets' parameter to this VPC is not specified, we will instead create one set of public subnets and one set of private, with a public/private pair in each Availability Zone in the target region.", @@ -117,19 +123,45 @@ def self.schema } }, "route_tables" => { - "type" => "array", - "items" => { - "type" => "object", - "required" => ["name", "routes"], - "description" => "A table of route entries, typically for use inside a VPC.", - "properties" => { - "name" => {"type" => "string"}, - "routes" => { - "type" => "array", - "items" => routeschema - } + "default_if" => [ + { + "key_is" => "create_standard_subnets", + "value_is" => true, + "set" => [ + { + "name" => "internet", + "routes" => [ { "destination_network" => "0.0.0.0/0", "gateway" => "#INTERNET" } ] + }, + { + "name" => "private", + "routes" => [ { "destination_network" => "0.0.0.0/0", "gateway" => "#NAT" } ] } + ] + }, + { + "key_is" => "create_standard_subnets", + "value_is" => false, + "set" => [ + { + "name" => "private", + "routes" => [ { "destination_network" => "0.0.0.0/0" } ] + } + ] + } + ], + "type" => "array", + "items" => { + "type" => "object", + "required" => ["name", "routes"], + "description" => "A table of route entries, typically for use inside a VPC.", + "properties" => { + "name" => {"type" => "string"}, + "routes" => { + "type" => "array", + "items" => routeschema + } } + } }, "subnets" => { "type" => "array", @@ -223,34 +255,40 @@ def self.schema # @param subnet_pref [String]: # @return [Hash] def self.reference(subnets = MANY_SUBNETS, nat_opts = NAT_OPTS, subnet_pref = nil) - vpc_ref_schema = { - "type" => "object", - "description" => "Deploy, attach, allow access from, or peer this resource with a VPC of VPCs.", - "minProperties" => 1, - "additionalProperties" => false, - "properties" => { - "vpc_id" => { - "type" => "string", - "description" => "Discover this VPC by looking for this cloud provider identifier." - }, - "credentials" => MU::Config.credentials_primitive, - "vpc_name" => { - "type" => "string", - "description" => "Discover this VPC by Mu-internal name; typically the shorthand 'name' field of a VPC declared elsewhere in the deploy, or in another deploy that's being referenced with 'deploy_id'." - }, - "region" => MU::Config.region_primitive, - "cloud" => MU::Config.cloud_primitive, - "tag" => { - "type" => "string", - "description" => "Discover this VPC by a cloud provider tag (key=value); note that this tag must not match more than one resource.", - "pattern" => "^[^=]+=.+" - }, - "deploy_id" => { - "type" => "string", - "description" => "Search for this VPC in an existing Mu deploy; specify a Mu deploy id (e.g. DEMO-DEV-2014111400-NG)." - } - } - } + schema_aliases = [ + { "vpc_id" => "id" }, + { "vpc_name" => "name" } + ] + vpc_ref_schema = MU::Config::Ref.schema(schema_aliases, type: "vpcs") + +# vpc_ref_schema = { +# "type" => "object", +# "description" => "Deploy, attach, allow access from, or peer this resource with a VPC of VPCs.", +# "minProperties" => 1, +# "additionalProperties" => false, +# "properties" => { +# "vpc_id" => { +# "type" => "string", +# "description" => "Discover this VPC by looking for this cloud provider identifier." +# }, +# "credentials" => MU::Config.credentials_primitive, +# "vpc_name" => { +# "type" => "string", +# "description" => "Discover this VPC by Mu-internal name; typically the shorthand 'name' field of a VPC declared elsewhere in the deploy, or in another deploy that's being referenced with 'deploy_id'." +# }, +# "region" => MU::Config.region_primitive, +# "cloud" => MU::Config.cloud_primitive, +# "tag" => { +# "type" => "string", +# "description" => "Discover this VPC by a cloud provider tag (key=value); note that this tag must not match more than one resource.", +# "pattern" => "^[^=]+=.+" +# }, +# "deploy_id" => { +# "type" => "string", +# "description" => "Search for this VPC in an existing Mu deploy; specify a Mu deploy id (e.g. DEMO-DEV-2014111400-NG)." +# } +# } +# } if nat_opts vpc_ref_schema["properties"].merge!( @@ -374,19 +412,155 @@ def self.routeschema def self.validate(vpc, configurator) ok = true - # Look for a common YAML screwup in route table land - if vpc['route_tables'] - vpc['route_tables'].each { |rtb| - next if !rtb['routes'] - rtb['routes'].each { |r| - if r.has_key?("gateway") and (!r["gateway"] or r["gateway"].to_s.empty?) - MU.log "Route gateway in VPC #{vpc['name']} cannot be nil- did you forget to puts quotes around a #INTERNET, #NAT, or #DENY?", MU::ERR, details: rtb - ok = false + have_public = false + have_private = false + + using_default_cidr = false + if !vpc['ip_block'] + if configurator.updating and configurator.existing_deploy and + configurator.existing_deploy.original_config['vpcs'] + pieces = [] + configurator.existing_deploy.original_config['vpcs'].each { |v| + if v['name'] == vpc['name'] + vpc['ip_block'] = v['ip_block'] + break + elsif v['virtual_name'] == vpc['name'] + vpc['ip_block'] = v['parent_block'] + break end } + else + using_default_cidr = true + vpc['ip_block'] = "10.0.0.0/16" + end + end + + # Look for a common YAML screwup in route table land + vpc['route_tables'].each { |rtb| + next if !rtb['routes'] + rtb['routes'].each { |r| + have_public = true if r['gateway'] == "#INTERNET" + have_private = true if r['gateway'] == "#NAT" or r['gateway'] == "#DENY" + # XXX the above logic doesn't cover VPN ids, peering connections, or + # instances used as routers. If you're doing anything that complex + # you should probably be declaring your own bastion hosts and + # routing behaviors, rather than relying on our inferred defaults. + if r.has_key?("gateway") and (!r["gateway"] or r["gateway"].to_s.empty?) + MU.log "Route gateway in VPC #{vpc['name']} cannot be nil- did you forget to puts quotes around a #INTERNET, #NAT, or #DENY?", MU::ERR, details: rtb + ok = false + end + } + rtb['routes'].uniq! + } + + peer_blocks = [] + siblings = configurator.haveLitterMate?(nil, "vpcs", has_multiple: true) + if siblings + siblings.each { |v| + next if v['name'] == vpc['name'] + peer_blocks << v['ip_block'] if v['ip_block'] } end + # if we're peering with other on-the-fly VPCs who might be using + # the default range, make sure our ip_blocks don't overlap + my_cidr = NetAddr::IPv4Net.parse(vpc['ip_block'].to_s) + if peer_blocks.size > 0 and using_default_cidr and !configurator.updating + begin + have_overlaps = false + peer_blocks.each { |cidr| + sibling_cidr = NetAddr::IPv4Net.parse(cidr.to_s) + have_overlaps = true if my_cidr.rel(sibling_cidr) != nil + } + if have_overlaps + my_cidr = my_cidr.next_sib + my_cidr = nil if my_cidr.to_s.match(/^10\.255\./) + end + end while have_overlaps + if !my_cidr.nil? and vpc['ip_block'] != my_cidr.to_s + vpc['ip_block'] = my_cidr.to_s + else + my_cidr = NetAddr::IPv4Net.parse(vpc['ip_block']) + end + end + + # Work out what we'll do + if have_private + vpc["cloud"] ||= MU.defaultCloud + + # See if we'll be able to create peering connections + can_peer = false + if MU.myCloud == vpc["cloud"] and MU.myVPCObj + peer_blocks.concat(MU.myVPCObj.routes) + begin + can_peer = true + peer_blocks.each { |cidr| + cidr_obj = NetAddr::IPv4Net.parse(cidr) + if my_cidr.rel(cidr_obj) != nil + can_peer = false + end + } + if !can_peer and using_default_cidr + my_cidr = my_cidr.next_sib + my_cidr = nil if my_cidr.to_s.match(/^10\.255\./) + end + end while !can_peer and using_default_cidr and !my_cidr.nil? + if !my_cidr.nil? and vpc['ip_block'] != my_cidr.to_s + vpc['ip_block'] = my_cidr.to_s + end + if using_default_cidr + MU.log "Defaulting address range for VPC #{vpc['name']} to #{vpc['ip_block']}", MU::NOTICE + end + if can_peer + vpc['peers'] ||= [] + vpc['peers'] << { + "vpc" => { "id" => MU.myVPC, "type" => "vpcs" } + } + elsif !configurator.updating + MU.log "#{vpc['ip_block']} overlaps with existing routes, will not be able to peer with Master's VPC", MU::WARN + end + end + + + # Feeling that, generate a generic bastion/NAT host to do the job. + # Clouds that don't have some kind of native NAT gateway can also + # leverage this host to honor "gateway" => "#NAT" situations. + if !can_peer and have_public and vpc["create_bastion"] + serverclass = Object.const_get("MU").const_get("Cloud").const_get(vpc["cloud"]).const_get("Server") + bastion = serverclass.genericNAT.dup + bastion["groomer_variables"] = { + "nat_ip_block" => vpc["ip_block"].to_s + } + bastion['name'] = vpc['name']+"-natstion" # XXX account for multiples somehow + bastion['credentials'] = vpc['credentials'] + bastion['ingress_rules'] ||= [] + ["tcp", "udp", "icmp"].each { |proto| + bastion['ingress_rules'] << { + "hosts" => [vpc["ip_block"].to_s], + "proto" => proto + } + } + bastion["vpc"] = { + "name" => vpc["name"], + "subnet_pref" => "public" + } + vpc["dependencies"] << { + "type" => "server", + "name" => bastion['name'], + } + vpc["bastion"] = MU::Config::Ref.get( + name: bastion['name'], + cloud: vpc['cloud'], + credentials: vpc['credentials'], + type: "servers" + ) + + ok = false if !configurator.insertKitten(bastion, "servers", true) + end + + end + + ok = false if !resolvePeers(vpc, configurator) ok @@ -404,22 +578,26 @@ def self.resolvePeers(vpc, configurator) append = [] delete = [] vpc["peers"].each { |peer| + if peer.nil? or !peer.is_a?(Hash) or !peer["vpc"] + MU.log "Skipping malformed VPC peer in #{vpc['name']}", MU::ERR, details: peer + next + end peer["#MU_CLOUDCLASS"] = Object.const_get("MU").const_get("Cloud").const_get("VPC") # We check for multiple siblings because some implementations # (Google) can split declared VPCs into parts to get the mimic the # routing behaviors we expect. - siblings = configurator.haveLitterMate?(peer['vpc']["vpc_name"], "vpcs", has_multiple: true) + siblings = configurator.haveLitterMate?(peer['vpc']["name"], "vpcs", has_multiple: true) # If we're peering with a VPC in this deploy, set it as a dependency - if !peer['vpc']["vpc_name"].nil? and siblings.size > 0 and + if !peer['vpc']["name"].nil? and siblings.size > 0 and peer["vpc"]['deploy_id'].nil? and peer["vpc"]['vpc_id'].nil? peer['vpc']['cloud'] = vpc['cloud'] if peer['vpc']['cloud'].nil? siblings.each { |sib| - if sib['name'] != peer['vpc']["vpc_name"] + if sib['name'] != peer['vpc']["name"] if sib['name'] != vpc['name'] append_me = { "vpc" => peer["vpc"].dup } - append_me['vpc']['vpc_name'] = sib['name'] + append_me['vpc']['name'] = sib['name'] append << append_me vpc["dependencies"] << { "type" => "vpc", @@ -430,7 +608,7 @@ def self.resolvePeers(vpc, configurator) else vpc["dependencies"] << { "type" => "vpc", - "name" => peer['vpc']["vpc_name"] + "name" => peer['vpc']["name"] } end delete << peer if sib['name'] == vpc['name'] @@ -461,17 +639,19 @@ def self.resolvePeers(vpc, configurator) ok end + + @@reference_cache = {} + # Pick apart an external VPC reference, validate it, and resolve it and its # various subnets and NAT hosts to live resources. # @param vpc_block [Hash]: # @param parent_type [String]: - # @param parent_name [String]: + # @param parent [MU::Cloud::VPC]: # @param configurator [MU::Config]: - # @param is_sibling [Boolean]: # @param sibling_vpcs [Array]: # @param dflt_region [String]: - def self.processReference(vpc_block, parent_type, parent_name, configurator, is_sibling: false, sibling_vpcs: [], dflt_region: MU.curRegion, credentials: nil) - puts vpc_block.ancestors if !vpc_block.is_a?(Hash) + def self.processReference(vpc_block, parent_type, parent, configurator, sibling_vpcs: [], dflt_region: MU.curRegion, dflt_project: nil, credentials: nil) + if !vpc_block.is_a?(Hash) and vpc_block.kind_of?(MU::Cloud::VPC) return true end @@ -480,8 +660,56 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ if vpc_block['region'].nil? and dflt_region and !dflt_region.empty? vpc_block['region'] = dflt_region.to_s end + dflt_region ||= vpc_block['region'] + vpc_block['name'] ||= vpc_block['vpc_name'] if vpc_block['vpc_name'] + vpc_block['id'] ||= vpc_block['vpc_id'] if vpc_block['vpc_id'] vpc_block['credentials'] ||= credentials if credentials + vpc_block['project'] ||= dflt_project if dflt_project + vpc_block["cloud"] ||= parent["cloud"] + +# XXX the right thing to do here is have a per-cloud callback hook for resolving +# projects/accounts/whatever, but for now let's get it working with Google's case + if vpc_block["cloud"] and vpc_block["cloud"] == "Google" and + vpc_block['project'] + vpc_block["habitat"] ||= MU::Cloud::Google.projectToRef(vpc_block['project'], config: configurator, credentials: vpc_block['credentials']).to_h + vpc_block.delete("project") + end + + # If this appears to be a sibling VPC that's destined to live in a + # sibling habitat, then by definition it doesn't exist yet. So don't + # try to do anything else clever here. +# XXX except maybe there's some stuff we should still do + if vpc_block["habitat"] and vpc_block["habitat"]["name"] and + !vpc_block["habitat"]["id"] + return ok + end + + # Resolve "forked" Google VPCs to the correct literal resources, based + # on the original reference to the (now virtual) parent VPC and, if + # set, subnet_pref or subnet_name + sibling_vpcs.each { |sibling| + if sibling['virtual_name'] and + sibling['virtual_name'] == vpc_block['name'] + if vpc_block['region'] and + sibling['regions'].include?(vpc_block['region']) + gateways = sibling['route_tables'].map { |rtb| + rtb['routes'].map { |r| r["gateway"] } + }.flatten.uniq + if ["public", "all_public"].include?(vpc_block['subnet_pref']) and + gateways.include?("#INTERNET") + vpc_block['name'] = sibling['name'] + break + elsif ["private", "all_private"].include?(vpc_block['subnet_pref']) and + !gateways.include?("#INTERNET") + vpc_block['name'] = sibling['name'] + break + end + end + end + } + + is_sibling = (vpc_block['name'] and configurator.haveLitterMate?(vpc_block["name"], "vpcs")) # Sometimes people set subnet_pref to "private" or "public" when they # mean "all_private" or "all_public." Help them out. @@ -496,27 +724,47 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ flags = {} flags["subnet_pref"] = vpc_block["subnet_pref"] if !vpc_block["subnet_pref"].nil? + hab_arg = if vpc_block['habitat'] + if vpc_block['habitat'].is_a?(MU::Config::Ref) + [vpc_block['habitat'].id] # XXX actually, findStray it + elsif vpc_block['habitat'].is_a?(Hash) + [vpc_block['habitat']['id']] # XXX actually, findStray it + else + [vpc_block['habitat'].to_s] + end + elsif vpc_block['project'] + [vpc_block['project']] + else + [] + end # First, dig up the enclosing VPC tag_key, tag_value = vpc_block['tag'].split(/=/, 2) if !vpc_block['tag'].nil? if !is_sibling begin if vpc_block['cloud'] != "CloudFormation" - found = MU::MommaCat.findStray( - vpc_block['cloud'], - "vpc", - deploy_id: vpc_block["deploy_id"], - cloud_id: vpc_block["vpc_id"], - name: vpc_block["vpc_name"], - credentials: vpc_block["credentials"], - tag_key: tag_key, - tag_value: tag_value, - region: vpc_block["region"], - flags: flags, - dummy_ok: true - ) - - ext_vpc = found.first if found.size == 1 + ext_vpc = if @@reference_cache[vpc_block] +MU.log "VPC lookup cache hit", MU::WARN, details: vpc_block + @@reference_cache[vpc_block] + else + found = MU::MommaCat.findStray( + vpc_block['cloud'], + "vpc", + deploy_id: vpc_block["deploy_id"], + cloud_id: vpc_block["id"], + name: vpc_block["name"], + credentials: vpc_block["credentials"], + tag_key: tag_key, + tag_value: tag_value, + region: vpc_block["region"], + flags: flags, + habitats: hab_arg, + dummy_ok: true + ) + + found.first if found and found.size == 1 + end + @@reference_cache[vpc_block] ||= ext_vpc # Make sure we don't have a weird mismatch between requested # credential sets and the VPC we actually found @@ -525,26 +773,27 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ if vpc_block['credentials'] and # probably can't happen vpc_block['credentials'] != ext_vpc.cloudobj.config["credentials"] ok = false - MU.log "#{parent_type} #{parent_name} requested a VPC on credentials '#{vpc_block['credentials']}' but matched VPC is under credentials '#{ext_vpc.cloudobj.config["credentials"]}'", MU::ERR, details: vpc_block + MU.log "#{parent_type} #{parent['name']} requested a VPC on credentials '#{vpc_block['credentials']}' but matched VPC is under credentials '#{ext_vpc.cloudobj.config["credentials"]}'", MU::ERR, details: vpc_block end if credentials and credentials != ext_vpc.cloudobj.config["credentials"] ok = false - MU.log "#{parent_type} #{parent_name} is using credentials '#{credentials}' but matched VPC is under credentials '#{ext_vpc.cloudobj.config["credentials"]}'", MU::ERR, details: vpc_block + MU.log "#{parent_type} #{parent['name']} is using credentials '#{credentials}' but matched VPC is under credentials '#{ext_vpc.cloudobj.config["credentials"]}'", MU::ERR, details: vpc_block end + @@reference_cache[vpc_block] ||= ext_vpc if ok vpc_block['credentials'] ||= ext_vpc.cloudobj.config["credentials"] end - + @@reference_cache[vpc_block] ||= ext_vpc if ok end rescue Exception => e raise MuError, e.inspect, e.backtrace ensure if !ext_vpc and vpc_block['cloud'] != "CloudFormation" - MU.log "Couldn't resolve VPC reference to a unique live VPC in #{parent_name} (called by #{caller[0]})", MU::ERR, details: vpc_block + MU.log "Couldn't resolve VPC reference to a unique live VPC in #{parent_type} #{parent['name']} (called by #{caller[0]})", MU::ERR, details: vpc_block return false - elsif !vpc_block["vpc_id"] - MU.log "Resolved VPC to #{ext_vpc.cloud_id} in #{parent_name}", MU::DEBUG, details: vpc_block - vpc_block["vpc_id"] = configurator.getTail("#{parent_name} Target VPC", value: ext_vpc.cloud_id, prettyname: "#{parent_name} Target VPC", cloudtype: "AWS::EC2::VPC::Id") + elsif !vpc_block["id"] + MU.log "Resolved VPC to #{ext_vpc.cloud_id} in #{parent['name']}", MU::DEBUG, details: vpc_block + vpc_block["id"] = configurator.getTail("#{parent['name']} Target VPC", value: ext_vpc.cloud_id, prettyname: "#{parent['name']} Target VPC", cloudtype: "AWS::EC2::VPC::Id") end end @@ -565,20 +814,20 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ nat_ip: vpc_block['nat_host_ip'] ) ssh_keydir = Etc.getpwnam(MU.mu_user).dir+"/.ssh" - if !vpc_block['nat_ssh_key'].nil? and !File.exists?(ssh_keydir+"/"+vpc_block['nat_ssh_key']) - MU.log "Couldn't find alternate NAT key #{ssh_keydir}/#{vpc_block['nat_ssh_key']} in #{parent_name}", MU::ERR, details: vpc_block + if !vpc_block['nat_ssh_key'].nil? and !File.exist?(ssh_keydir+"/"+vpc_block['nat_ssh_key']) + MU.log "Couldn't find alternate NAT key #{ssh_keydir}/#{vpc_block['nat_ssh_key']} in #{parent['name']}", MU::ERR, details: vpc_block return false end if !ext_nat if vpc_block["nat_host_id"].nil? and nat_tag_key.nil? and vpc_block['nat_host_ip'].nil? and vpc_block["deploy_id"].nil? - MU.log "Couldn't resolve NAT host to a live instance in #{parent_name}.", MU::DEBUG, details: vpc_block + MU.log "Couldn't resolve NAT host to a live instance in #{parent['name']}.", MU::DEBUG, details: vpc_block else - MU.log "Couldn't resolve NAT host to a live instance in #{parent_name}", MU::ERR, details: vpc_block + MU.log "Couldn't resolve NAT host to a live instance in #{parent['name']}", MU::ERR, details: vpc_block return false end elsif !vpc_block["nat_host_id"] - MU.log "Resolved NAT host to #{ext_nat.cloud_id} in #{parent_name}", MU::DEBUG, details: vpc_block + MU.log "Resolved NAT host to #{ext_nat.cloud_id} in #{parent['name']}", MU::DEBUG, details: vpc_block vpc_block["nat_host_id"] = ext_nat.cloud_id vpc_block.delete('nat_host_name') vpc_block.delete('nat_host_ip') @@ -600,13 +849,13 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ if ext_subnet.nil? and vpc_block["cloud"] != "CloudFormation" ok = false - MU.log "Couldn't resolve subnet reference (list) in #{parent_name} to a live subnet", MU::ERR, details: subnet + MU.log "Couldn't resolve subnet reference (list) in #{parent['name']} to a live subnet", MU::ERR, details: subnet elsif !subnet['subnet_id'] subnet['subnet_id'] = ext_subnet.cloud_id subnet['az'] = ext_subnet.az subnet.delete('subnet_name') subnet.delete('tag') - MU.log "Resolved subnet reference in #{parent_name} to #{ext_subnet.cloud_id}", MU::DEBUG, details: subnet + MU.log "Resolved subnet reference in #{parent['name']} to #{ext_subnet.cloud_id}", MU::DEBUG, details: subnet end } # ...others single subnets @@ -619,13 +868,13 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ if ext_subnet.nil? ok = false - MU.log "Couldn't resolve subnet reference (name/id) in #{parent_name} to a live subnet", MU::ERR, details: vpc_block + MU.log "Couldn't resolve subnet reference (name/id) in #{parent['name']} to a live subnet", MU::ERR, details: vpc_block elsif !vpc_block['subnet_id'] vpc_block['subnet_id'] = ext_subnet.cloud_id vpc_block['az'] = ext_subnet.az vpc_block.delete('subnet_name') vpc_block.delete('subnet_pref') - MU.log "Resolved subnet reference in #{parent_name} to #{ext_subnet.cloud_id}", MU::DEBUG, details: vpc_block + MU.log "Resolved subnet reference in #{parent['name']} to #{ext_subnet.cloud_id}", MU::DEBUG, details: vpc_block end end end @@ -641,7 +890,7 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ honor_subnet_prefs=false end if !subnet['subnet_id'].nil? and subnet['subnet_id'].is_a?(String) - subnet['subnet_id'] = configurator.getTail("Subnet #{count} for #{parent_name}", value: subnet['subnet_id'], prettyname: "Subnet #{count} for #{parent_name}", cloudtype: "AWS::EC2::Subnet::Id") + subnet['subnet_id'] = configurator.getTail("Subnet #{count} for #{parent['name']}", value: subnet['subnet_id'], prettyname: "Subnet #{count} for #{parent['name']}", cloudtype: "AWS::EC2::Subnet::Id") count = count + 1 end } @@ -662,11 +911,11 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ ext_vpc.subnets.each { |subnet| next if dflt_region and vpc_block["cloud"] == "Google" and subnet.az != dflt_region if subnet.private? and (vpc_block['subnet_pref'] != "all_public" and vpc_block['subnet_pref'] != "public") - private_subnets << { "subnet_id" => configurator.getTail("#{parent_name} Private Subnet #{priv}", value: subnet.cloud_id, prettyname: "#{parent_name} Private Subnet #{priv}", cloudtype: "AWS::EC2::Subnet::Id"), "az" => subnet.az } + private_subnets << { "subnet_id" => configurator.getTail("#{parent['name']} Private Subnet #{priv}", value: subnet.cloud_id, prettyname: "#{parent['name']} Private Subnet #{priv}", cloudtype: "AWS::EC2::Subnet::Id"), "az" => subnet.az } private_subnets_map[subnet.cloud_id] = subnet priv = priv + 1 elsif !subnet.private? and vpc_block['subnet_pref'] != "all_private" and vpc_block['subnet_pref'] != "private" - public_subnets << { "subnet_id" => configurator.getTail("#{parent_name} Public Subnet #{pub}", value: subnet.cloud_id, prettyname: "#{parent_name} Public Subnet #{pub}", cloudtype: "AWS::EC2::Subnet::Id"), "az" => subnet.az } + public_subnets << { "subnet_id" => configurator.getTail("#{parent['name']} Public Subnet #{pub}", value: subnet.cloud_id, prettyname: "#{parent['name']} Public Subnet #{pub}", cloudtype: "AWS::EC2::Subnet::Id"), "az" => subnet.az } public_subnets_map[subnet.cloud_id] = subnet pub = pub + 1 else @@ -674,12 +923,15 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ end } else - sibling_vpcs.each { |ext_vpc| - if ext_vpc['name'].to_s == vpc_block['vpc_name'].to_s and ext_vpc['subnets'] + sibling_vpcs.each { |sibling_vpc| + if (sibling_vpc['name'].to_s == vpc_block['name'].to_s or + sibling_vpc['virtual_name'].to_s == vpc_block['name'].to_s) and + sibling_vpc['subnets'] subnet_ptr = "subnet_name" - ext_vpc['subnets'].each { |subnet| - next if dflt_region and vpc_block["cloud"] == "Google" and subnet['availability_zone'] != dflt_region - if subnet['is_public'] # NAT nonsense calculated elsewhere, ew + + sibling_vpc['subnets'].each { |subnet| + next if dflt_region and vpc_block["cloud"].to_s == "Google" and subnet['availability_zone'] != dflt_region + if subnet['is_public'] public_subnets << {"subnet_name" => subnet['name'].to_s} else private_subnets << {"subnet_name" => subnet['name'].to_s} @@ -689,13 +941,12 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ end end } - break end } end if public_subnets.size == 0 and private_subnets == 0 - MU.log "Couldn't find any subnets for #{parent_name}", MU::ERR + MU.log "Couldn't find any subnets for #{parent['name']}", MU::ERR return false end all_subnets = public_subnets + private_subnets @@ -705,14 +956,16 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ if !public_subnets.nil? and public_subnets.size > 0 vpc_block.merge!(public_subnets[rand(public_subnets.length)]) if public_subnets else - MU.log "Public subnet requested for #{parent_name}, but none found in #{vpc_block}", MU::ERR + MU.log "Public subnet requested for #{parent_type} #{parent['name']}, but none found among #{all_subnets.join(", ")}", MU::ERR, details: vpc_block.to_h + pp is_sibling return false end when "private" if !private_subnets.nil? and private_subnets.size > 0 vpc_block.merge!(private_subnets[rand(private_subnets.length)]) else - MU.log "Private subnet requested for #{parent_name}, but none found in #{vpc_block}", MU::ERR + MU.log "Private subnet requested for #{parent_type} #{parent['name']}, but none found among #{all_subnets.join(", ")}", MU::ERR, details: vpc_block.to_h + pp is_sibling return false end if !is_sibling and !private_subnets_map[vpc_block[subnet_ptr]].nil? @@ -748,9 +1001,9 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ else vpc_block['subnets'] ||= [] - sibling_vpcs.each { |ext_vpc| - next if ext_vpc["name"] != vpc_block["vpc_name"] - ext_vpc["subnets"].each { |subnet| + sibling_vpcs.each { |sibling_vpc| + next if sibling_vpc["name"] != vpc_block["name"] + sibling_vpc["subnets"].each { |subnet| if subnet["route_table"] == vpc_block["subnet_pref"] vpc_block["subnets"] << subnet end @@ -784,16 +1037,16 @@ def self.processReference(vpc_block, parent_type, parent_name, configurator, is_ } end - vpc_block.delete('deploy_id') - vpc_block.delete('vpc_name') if vpc_block.has_key?('vpc_id') + vpc_block.delete('id') if vpc_block['id'].nil? + vpc_block.delete('name') if vpc_block.has_key?('id') vpc_block.delete('tag') - MU.log "Resolved VPC resources for #{parent_name}", MU::DEBUG, details: vpc_block + MU.log "Resolved VPC resources for #{parent['name']}", MU::DEBUG, details: vpc_block end - if !vpc_block["vpc_id"].nil? and vpc_block["vpc_id"].is_a?(String) - vpc_block["vpc_id"] = configurator.getTail("#{parent_name}vpc_id", value: vpc_block["vpc_id"], prettyname: "#{parent_name} Target VPC", cloudtype: "AWS::EC2::VPC::Id") + if !vpc_block["id"].nil? and vpc_block["id"].is_a?(String) + vpc_block["id"] = configurator.getTail("#{parent['name']}_id", value: vpc_block["id"], prettyname: "#{parent['name']} Target VPC", cloudtype: "AWS::EC2::VPC::Id") elsif !vpc_block["nat_host_name"].nil? and vpc_block["nat_host_name"].is_a?(String) - vpc_block["nat_host_name"] = MU::Config::Tail.new("#{parent_name}nat_host_name", vpc_block["nat_host_name"]) + vpc_block["nat_host_name"] = MU::Config::Tail.new("#{parent['name']}nat_host_name", vpc_block["nat_host_name"]) end diff --git a/modules/mu/defaults/amazon_images.yaml b/modules/mu/defaults/AWS.yaml similarity index 71% rename from modules/mu/defaults/amazon_images.yaml rename to modules/mu/defaults/AWS.yaml index 50725bd96..34823eacc 100644 --- a/modules/mu/defaults/amazon_images.yaml +++ b/modules/mu/defaults/AWS.yaml @@ -1,5 +1,5 @@ --- -rhel71: &rhel71 +rhel71: &4 us-east-1: ami-001ac4e5f414fc5b7 ap-northeast-1: ami-0094aa64967e9267d ap-northeast-2: ami-072783bf50ca01ef7 @@ -16,24 +16,24 @@ rhel71: &rhel71 us-east-2: ami-00cc9f964320129d7 us-west-1: ami-0110a4f8a96b24369 us-west-2: ami-0006d7ea260e40777 -centos6: ¢os6 - us-east-1: ami-02f468ed191eda16e - ap-northeast-1: ami-0211c03a05f62bd88 - ap-northeast-2: ami-0c4e210038a6c79a0 - ap-south-1: ami-0679b5acba08097b4 - ap-southeast-1: ami-0d06903ec09d67b84 - ap-southeast-2: ami-07fcb5609d11b08c2 - ca-central-1: ami-0090ac3d146715c76 - eu-central-1: ami-0402323ff6cf59dc3 - eu-north-1: ami-0c8c61870da56e00c - eu-west-1: ami-02b5e95243d740d41 - eu-west-2: ami-0d3086afa04c0bac9 - eu-west-3: ami-07810e092578b4d32 - sa-east-1: ami-05f5c9f4fd81c0ae8 - us-east-2: ami-039668d561db19b15 - us-west-1: ami-09403e40a997f60bf - us-west-2: ami-0728d0eef40a5430b -centos7: ¢os7 +centos6: &3 + us-east-1: ami-06a4e11df81ad5f36 + ap-northeast-1: ami-0c777a90d1239a89f + ap-northeast-2: ami-00a536043e60d99e0 + ap-south-1: ami-0dbe9e035d242ab19 + ap-southeast-1: ami-07a0370b2aaca3d83 + ap-southeast-2: ami-0df17df004fb2d85a + ca-central-1: ami-0852062afaf78369b + eu-central-1: ami-058aab5f423dea3fa + eu-north-1: ami-040fd7c04441f6c6b + eu-west-1: ami-0a0595aeafc8430b2 + eu-west-2: ami-0f45a65b48147cbd2 + eu-west-3: ami-0fa2dea3f867cd713 + sa-east-1: ami-0e49de35e61a9bf6a + us-east-2: ami-06c0d5b6dbb7494c7 + us-west-1: ami-08c8ff991d27c3a25 + us-west-2: ami-0b62b81cae1d662c9 +centos7: us-east-1: ami-0d98f625837fb042e ap-northeast-1: ami-0f86cf4470a454938 ap-northeast-2: ami-012efba9ff5419589 @@ -50,29 +50,29 @@ centos7: ¢os7 us-east-2: ami-00c5f50b43ecdb248 us-west-1: ami-0cc4055a6417e4df6 us-west-2: ami-0c250c14d31f0847f -ubuntu16: &ubuntu16 +ubuntu16: &2 us-east-1: ami-bcdc16c6 us-west-1: ami-1b17257b us-west-2: ami-19e92861 - eu-west-1: ami-eed00d97 + eu-west-1: ami-eed00d97 eu-central-1: ami-e613ac89 sa-east-1: ami-1ca7d970 ap-northeast-1: ami-6959870f ap-northeast-2: ami-08d77266 - ap-southeast-1: ami-d9dca7ba + ap-southeast-1: ami-d9dca7ba ap-southeast-2: ami-02ad4060 -ubuntu14: &ubuntu14 +ubuntu14: us-east-1: ami-663a6e0c us-west-1: ami-13988772 us-west-2: ami-b885eed8 - eu-west-1: ami-b265c7c1 + eu-west-1: ami-b265c7c1 eu-central-1: ami-ad8894c1 sa-east-1: ami-4196112d ap-northeast-1: ami-575b6e39 ap-northeast-2: ami-f0ac629e - ap-southeast-1: ami-2855964b + ap-southeast-1: ami-2855964b ap-southeast-2: ami-d19fc4b2 -win2k12r2: &win2k12r2 +win2k12r2: &1 us-east-1: ami-d4409aae us-east-2: ami-fbbe929e us-west-1: ami-ec91ac8c @@ -85,14 +85,13 @@ win2k12r2: &win2k12r2 ap-southeast-1: ami-b61657d5 ap-southeast-2: ami-9a7b97f8 ap-south-1: ami-99a8eaf6 - ca-central-1: ami-608b3304 -win2k16: &win2k16 + ca-central-1: ami-608b3304 +win2k16: us-east-1: ami-d2cb25a8 us-east-2: ami-2db59748 us-west-1: ami-2db59748 us-west-2: ami-3b47ba43 eu-central-1: ami-37d46558 - eu-west-1: ami-53408c2a eu-west-1: ami-06c5d662 sa-east-1: ami-53fd803f ap-northeast-1: ami-ce8b42a8 @@ -101,11 +100,12 @@ win2k16: &win2k16 ap-southeast-2: ami-792bcd1b ap-south-1: ami-448dcb2b ca-central-1: ami-a39920c7 -amazon: &amazon2016 +amazon: us-east-1: ami-b73b63a0 us-east-2: ami-58277d3d us-west-1: ami-23e8a343 us-west-2: ami-5ec1673e + us-gov-east-1: ami-fdaf4e8c eu-central-1: ami-f9619996 eu-west-1: ami-9398d3e0 sa-east-1: ami-97831ffb @@ -113,12 +113,10 @@ amazon: &amazon2016 ap-northeast-2: ami-983ce8f6 ap-southeast-1: ami-b953f2da ap-southeast-2: ami-db704cb8 -win2k12: *win2k12r2 -win2k16: *win2k16 -windows: *win2k12r2 -ubuntu: *ubuntu16 -centos: *centos6 -rhel7: *rhel71 -rhel: *rhel71 -linux: *centos6 -amazon: *amazon2016 +win2k12: *1 +windows: *1 +ubuntu: *2 +centos: *3 +rhel7: *4 +rhel: *4 +linux: *3 diff --git a/modules/mu/defaults/Azure.yaml b/modules/mu/defaults/Azure.yaml new file mode 100644 index 000000000..057aa2e3e --- /dev/null +++ b/modules/mu/defaults/Azure.yaml @@ -0,0 +1,17 @@ +--- +centos6: ¢os6 OpenLogic/CentOS/6 +#centos7: ¢os7 westernoceansoftwaresprivatelimited/centos-7-6/centos-7-6-server +centos7: ¢os7 OpenLogic/CentOS/7 +rhel8: &rhel8 RedHat/RHEL/8 +rhel7: &rhel7 RedHat/RHEL/7 +rhel6: &rhel6 RedHat/RHEL/6 +debian10: &debian10 Debian/debian-10/10 +win2k12r2: &win2k12r2 MicrosoftWindowsServer/WindowsServer/2012-R2-Datacenter +win2k16: &win2k16 MicrosoftWindowsServer/WindowsServer/2016-Datacenter +win2k19: &win2k19 MicrosoftWindowsServer/WindowsServer/2019-Datacenter +win2k12: *win2k12r2 +windows: *win2k16 +centos: *centos7 +rhel: *rhel7 +linux: *centos7 +debian: *debian10 diff --git a/modules/mu/defaults/Google.yaml b/modules/mu/defaults/Google.yaml new file mode 100644 index 000000000..498683176 --- /dev/null +++ b/modules/mu/defaults/Google.yaml @@ -0,0 +1,24 @@ +--- +centos6: ¢os6 centos-cloud/centos-6 +centos7: ¢os7 centos-cloud/centos-7 +rhel71: &rhel71 rhel-cloud/rhel-7 +rhel6: &rhel6 rhel-cloud/rhel-6 +debian10: &debian10 debian-cloud/debian-10 +debian9: &debian9 debian-cloud/debian-9 +ubuntu14: &ubuntu14 ubuntu-os-cloud/ubuntu-1404-lts +ubuntu16: &ubuntu16 ubuntu-os-cloud/ubuntu-1604-lts +ubuntu18: &ubuntu18 ubuntu-os-cloud/ubuntu-1804-lts +ubuntu19: &ubuntu19 ubuntu-os-cloud/ubuntu-1904 +win2k12r2: &win2k12r2 windows-cloud/windows-2012-r2 +win2k16: &win2k16 windows-cloud/windows-2016 +win2k19: &win2k19 windows-cloud/windows-2019 +google: &google coreos-cloud/coreos-stable +coreos: *google +win2k12: *win2k12r2 +windows: *win2k16 +ubuntu: *ubuntu18 +centos: *centos7 +rhel7: *rhel71 +rhel: *rhel71 +linux: *centos7 +debian: *debian10 diff --git a/modules/mu/defaults/README.md b/modules/mu/defaults/README.md index 3531329d6..8f3f259b0 100644 --- a/modules/mu/defaults/README.md +++ b/modules/mu/defaults/README.md @@ -1,2 +1,2 @@ Do not edit these files in place! Instead, copy them to $install_prefix/etc -(e.g. /opt/mu/etc/amazon_images.yaml) and make customizations there. +(e.g. /opt/mu/etc/AWS.yaml) and make customizations there. diff --git a/modules/mu/defaults/google_images.yaml b/modules/mu/defaults/google_images.yaml deleted file mode 100644 index 7922932b3..000000000 --- a/modules/mu/defaults/google_images.yaml +++ /dev/null @@ -1,16 +0,0 @@ ---- -centos6: ¢os6 projects/my-project-1474050033734/global/images/mu-dev-2017081809-tr-centos6 -centos7: ¢os7 projects/centos-cloud/global/images/centos-7-v20170620 -rhel71: &rhel71 projects/rhel-cloud/global/images/rhel-7-v20170620 -ubuntu14: &ubuntu14 projects/ubuntu-os-cloud/global/images/ubuntu-1404-trusty-v20170619 -win2k12r2: &win2k12r2 projects/windows-cloud/global/images/windows-server-2012-r2-dc-v20170615 -win2k16: &win2k16 projects/windows-cloud/global/images/windows-server-2016-dc-v20170615 -google: &google projects/coreos-cloud/global/images/cos-stable-59-9460-64-0 -win2k12: *win2k12r2 -win2k16: *win2k16 -windows: *win2k12r2 -ubuntu: *ubuntu14 -centos: *centos7 -rhel7: *rhel71 -rhel: *rhel71 -linux: *centos7 diff --git a/modules/mu/deploy.rb b/modules/mu/deploy.rb index 7bac6081f..ef2437ef1 100644 --- a/modules/mu/deploy.rb +++ b/modules/mu/deploy.rb @@ -50,6 +50,9 @@ class Deploy # We just pass this flag to MommaCat, telling it not to save any metadata. attr_reader :no_artifacts + # The deployment object we create for our stack + attr_reader :mommacat + # Indicates whether we are updating an existing deployment, as opposed to # creating a new one. attr_reader :updating @@ -66,6 +69,7 @@ class Deploy # @param deploy_id [String]: Reload and re-process an existing deploy def initialize(environment, verbosity: MU::Logger::NORMAL, + color: true, webify_logs: false, nocleanup: false, cloudformation_path: nil, @@ -76,12 +80,14 @@ def initialize(environment, deploy_id: nil, deploy_obj: nil) MU.setVar("verbosity", verbosity) + MU.setVar("color", color) @webify_logs = webify_logs @verbosity = verbosity + @color = color @nocleanup = nocleanup @no_artifacts = no_artifacts @reraise_thread = reraise_thread - MU.setLogging(verbosity, webify_logs) + MU.setLogging(verbosity, webify_logs, STDOUT, color) MU::Cloud::CloudFormation.emitCloudFormation(set: force_cloudformation) @cloudformation_output = cloudformation_path @@ -96,7 +102,7 @@ def initialize(environment, @dependency_semaphore = Mutex.new @main_config = stack_conf - @original_config = Marshal.load(Marshal.dump(stack_conf)) + @original_config = Marshal.load(Marshal.dump(MU.structToHash(stack_conf.dup))) @original_config.freeze @admins = stack_conf["admins"] @mommacat = deploy_obj @@ -109,7 +115,7 @@ def initialize(environment, @updating = false time=Time.new @appname = stack_conf["appname"] - @timestamp = time.strftime("%Y%m%d%H").to_s; + @timestamp = time.strftime("%Y%m%d%H").to_s @timestamp.freeze @timestart = time.to_s; @timestart.freeze @@ -131,17 +137,13 @@ def initialize(environment, MU.log "Deployment id: #{MU.appname} \"#{MU.handle}\" (#{MU.deploy_id})" end - # Instance variables that are effectively class variables - @my_instance_id = MU::Cloud::AWS.getAWSMetaData("instance-id") - @my_az = MU::Cloud::AWS.getAWSMetaData("placement/availability-zone") - - @fromName ='chef-server'; + @fromName = MU.muCfg['mu_admin_email'] MU::Cloud.resource_types.each { |cloudclass, data| if !@main_config[data[:cfg_plural]].nil? and @main_config[data[:cfg_plural]].size > 0 @main_config[data[:cfg_plural]].each { |resource| if force_cloudformation - if resource['cloud'] = "AWS" + if resource['cloud'] == "AWS" resource['cloud'] = "CloudFormation" if resource.has_key?("vpc") and resource["vpc"].is_a?(Hash) resource["vpc"]['cloud'] = "CloudFormation" @@ -205,12 +207,26 @@ def run if !die puts "Received SIGINT, hit ctrl-C again within five seconds to kill this deployment." else - raise "Terminated by user" + Thread.list.each do |t| + next if !t.status + if t.object_id != Thread.current.object_id and + t.thread_variable_get("name") != "main_thread" and + t.thread_variable_get("owned_by_mu") + t.kill + end + end + + if @main_thread + @main_thread.raise "Terminated by user" + else + raise "Terminated by user" + end end @last_sigterm = Time.now.to_i end begin + @main_thread = Thread.current if !@mommacat metadata = { "appname" => @appname, @@ -238,16 +254,25 @@ def run @admins.each { |admin| @mommacat.notify("admins", admin['name'], admin) } + if @mommacat.numKittens(types: ["Server", "ServerPool"]) > 0 + MU::MommaCat.start + end @deploy_semaphore = Mutex.new parent_thread_id = Thread.current.object_id - @main_thread = Thread.current + + # Run cloud provider-specific deploy meta-artifact creation (ssh keys, + # resource groups, etc) + @mommacat.cloudsUsed.each { |cloud| + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud) + cloudclass.initDeploy(@mommacat) + } # Kick off threads to create each of our new servers. @my_threads << Thread.new { MU.dupGlobals(parent_thread_id) Thread.current.thread_variable_set("name", "mu_create_container") - Thread.abort_on_exception = true +# Thread.abort_on_exception = false MU::Cloud.resource_types.each { |cloudclass, data| if !@main_config[data[:cfg_plural]].nil? and @main_config[data[:cfg_plural]].size > 0 and @@ -261,7 +286,7 @@ def run @my_threads << Thread.new { MU.dupGlobals(parent_thread_id) Thread.current.thread_variable_set("name", "mu_groom_container") - Thread.abort_on_exception = true +# Thread.abort_on_exception = false MU::Cloud.resource_types.each { |cloudclass, data| if !@main_config[data[:cfg_plural]].nil? and @main_config[data[:cfg_plural]].size > 0 and @@ -283,9 +308,13 @@ def run t.join end + @mommacat.save! + rescue Exception => e @my_threads.each do |t| - if t.object_id != Thread.current.object_id and t.thread_variable_get("name") != "main_thread" and t.object_id != parent_thread_id + if t.object_id != Thread.current.object_id and + t.thread_variable_get("name") != "main_thread" and + t.object_id != parent_thread_id MU::MommaCat.unlockAll t.kill end @@ -294,14 +323,17 @@ def run # If it was a regular old exit, we assume something deeper in already # handled logging and cleanup for us, and just quietly go away. if e.class.to_s != "SystemExit" - MU.log e.inspect, MU::ERR, details: e.backtrace if @verbosity != MU::Logger::SILENT + MU.log e.class.name+": "+e.message, MU::ERR, details: e.backtrace if @verbosity != MU::Logger::SILENT if !@nocleanup - Thread.list.each do |t| - if t.object_id != Thread.current.object_id and t.thread_variable_get("name") != "main_thread" and t.object_id != parent_thread_id - t.kill - end - end - MU::Cleanup.run(MU.deploy_id, skipsnapshots: true, verbosity: @verbosity, mommacat: @mommacat) + + # Wrap this in a thread to protect the Azure SDK from imploding + # because it mistakenly thinks there's a deadlock. + cleanup_thread = Thread.new { + MU.dupGlobals(parent_thread_id) + Thread.abort_on_exception = false + MU::Cleanup.run(MU.deploy_id, skipsnapshots: true, verbosity: @verbosity, mommacat: @mommacat) + } + cleanup_thread.join @nocleanup = true # so we don't run this again later end end @@ -333,7 +365,7 @@ def run if MU.myCloud == "AWS" MU::Cloud::AWS.openFirewallForClients # XXX add the other clouds, or abstract end - MU::MommaCat.getLitter(MU.deploy_id, use_cache: false) +# MU::MommaCat.getLitter(MU.deploy_id, use_cache: false) if @mommacat.numKittens(types: ["Server", "ServerPool"]) > 0 # MU::MommaCat.syncMonitoringConfig # TODO only invoke if Server or ServerPool actually changed something when @updating end @@ -401,19 +433,7 @@ def run } end - if $MU_CFG['slack'] and $MU_CFG['slack']['webhook'] and - (!$MU_CFG['slack']['skip_environments'] or !$MU_CFG['slack']['skip_environments'].any?{ |s| s.casecmp(MU.environment)==0 }) - require 'slack-notifier' - slack = Slack::Notifier.new $MU_CFG['slack']['webhook'] - - slack.ping "Mu deployment #{MU.appname} *\"#{MU.handle}\"* (`#{MU.deploy_id}`) successfully completed on *#{$MU_CFG['hostname']}* (#{$MU_CFG['public_address']})", channel: $MU_CFG['slack']['channel'] - if MU.summary.size > 0 - MU.summary.each { |msg| - slack.ping msg, channel: $MU_CFG['slack']['channel'] - } - end - end - + @mommacat.sendAdminSlack("Deploy completed succesfully", msg: MU.summary.join("\n")) end private @@ -431,6 +451,7 @@ def sendMail() $str += JSON.pretty_generate(@mommacat.deployment) admin_addrs = @admins.map { |admin| + admin['name'] ||= "" admin['name']+" <"+admin['email']+">" } @@ -516,7 +537,7 @@ def setThreadDependencies(services) services.each { |resource| if !resource["#MU_CLOUDCLASS"] - pp resource +# pp resource end res_type = resource["#MU_CLOUDCLASS"].cfg_name name = res_type+"_"+resource["name"] @@ -531,9 +552,10 @@ def setThreadDependencies(services) if resource["dependencies"] != nil then resource["dependencies"].each { |dependency| parent_class = nil - MU::Cloud.resource_types.each_pair { |name, attrs| - if attrs[:cfg_name] == dependency['type'] - parent_class = Object.const_get("MU").const_get("Cloud").const_get(name) + MU::Cloud.resource_types.each_pair { |res_class, attrs| + if attrs[:cfg_name] == dependency['type'] or + attrs[:cfg_plural] == dependency['type'] + parent_class = Object.const_get("MU").const_get("Cloud").const_get(res_class) break end } @@ -576,109 +598,137 @@ def createResources(services, mode="create") parent_thread = Thread.current services.uniq! services.each do |service| - @my_threads << Thread.new(service) { |myservice| - MU.dupGlobals(parent_thread_id) - threadname = service["#MU_CLOUDCLASS"].cfg_name+"_"+myservice["name"]+"_#{mode}" - Thread.current.thread_variable_set("name", threadname) - Thread.abort_on_exception = true - waitOnThreadDependencies(threadname) - - if service["#MU_CLOUDCLASS"].instance_methods(false).include?(:groom) and !service['dependencies'].nil? and !service['dependencies'].size == 0 - if mode == "create" - MU::MommaCat.lock(service["#MU_CLOUDCLASS"].cfg_name+"_"+myservice["name"]+"-dependencies") - elsif mode == "groom" - MU::MommaCat.unlock(service["#MU_CLOUDCLASS"].cfg_name+"_"+myservice["name"]+"-dependencies") + begin + @my_threads << Thread.new(service) { |myservice| + MU.dupGlobals(parent_thread_id) + threadname = myservice["#MU_CLOUDCLASS"].cfg_name+"_"+myservice["name"]+"_#{mode}" + Thread.current.thread_variable_set("name", threadname) + Thread.current.thread_variable_set("owned_by_mu", true) +# Thread.abort_on_exception = false + waitOnThreadDependencies(threadname) + + if myservice["#MU_CLOUDCLASS"].instance_methods(false).include?(:groom) and !myservice['dependencies'].nil? and !myservice['dependencies'].size == 0 + if mode == "create" + MU::MommaCat.lock(myservice["#MU_CLOUDCLASS"].cfg_name+"_"+myservice["name"]+"-dependencies") + elsif mode == "groom" + MU::MommaCat.unlock(myservice["#MU_CLOUDCLASS"].cfg_name+"_"+myservice["name"]+"-dependencies") + end end - end - MU.log "Launching thread #{threadname}", MU::DEBUG - begin - if service['#MUOBJECT'].nil? - service['#MUOBJECT'] = service["#MU_CLOUDCLASS"].new(mommacat: @mommacat, kitten_cfg: myservice, delayed_save: @updating) - end - rescue Exception => e - MU::MommaCat.unlockAll - @main_thread.raise MuError, "Error instantiating object from #{service["#MU_CLOUDCLASS"]} (#{e.inspect})", e.backtrace - raise e - end - begin - run_this_method = service['#MUOBJECT'].method(mode) - rescue Exception => e - MU::MommaCat.unlockAll - @main_thread.raise MuError, "Error invoking #{service["#MU_CLOUDCLASS"]}.#{mode} for #{myservice['name']} (#{e.inspect})", e.backtrace - raise e - end - begin - MU.log "Checking whether to run #{service['#MUOBJECT']}.#{mode} (updating: #{@updating})", MU::DEBUG - if !@updating or mode != "create" - myservice = run_this_method.call - else - - # XXX experimental create behavior for --liveupdate flag, only works on a couple of resource types. Inserting new resources into an old deploy is tricky. - opts = {} - if service["#MU_CLOUDCLASS"].cfg_name == "loadbalancer" - opts['classic'] = service['classic'] ? true : false + MU.log "Launching thread #{threadname}", MU::DEBUG + begin + if myservice['#MUOBJECT'].nil? + if @mommacat + ext_obj = @mommacat.findLitterMate(type: myservice["#MU_CLOUDCLASS"].cfg_plural, name: myservice['name'], credentials: myservice['credentials'], created_only: true, return_all: false) + if @updating + raise MuError, "Failed to findLitterMate(type: #{myservice["#MU_CLOUDCLASS"].cfg_plural}, name: #{myservice['name']}, credentials: #{myservice['credentials']}, created_only: true, return_all: false) in deploy #{@mommacat.deploy_id}" if !ext_obj + ext_obj.config!(myservice) + end + myservice['#MUOBJECT'] = ext_obj + end + myservice['#MUOBJECT'] ||= myservice["#MU_CLOUDCLASS"].new(mommacat: @mommacat, kitten_cfg: myservice, delayed_save: @updating) end + rescue RuntimeError => e + # cloud implementations can iterate over these same hashes, + # which can throw this if we catch them at the wrong moment. + # here's your hacky workaround. + if e.message.match(/can't add a new key into hash during iteration/) + MU.log e.message+" in main deploy thread, probably transient", MU::DEBUG + sleep 1 + retry + else + raise e + end + rescue Exception => e + MU::MommaCat.unlockAll + @main_thread.raise MuError, "Error instantiating object from #{myservice["#MU_CLOUDCLASS"]} (#{e.inspect})", e.backtrace + raise e + end + begin + run_this_method = myservice['#MUOBJECT'].method(mode) + rescue Exception => e + MU::MommaCat.unlockAll + @main_thread.raise MuError, "Error invoking #{myservice["#MU_CLOUDCLASS"]}.#{mode} for #{myservice['name']} (#{e.inspect})", e.backtrace + raise e + end + begin + MU.log "Checking whether to run #{myservice['#MUOBJECT']}.#{mode} (updating: #{@updating})", MU::DEBUG + if !@updating or mode != "create" + myservice = run_this_method.call + else - found = MU::MommaCat.findStray(service['cloud'], - service["#MU_CLOUDCLASS"].cfg_name, - name: service['name'], - region: service['region'], - deploy_id: @mommacat.deploy_id, -# allow_multi: service["#MU_CLOUDCLASS"].has_multiple, - tag_key: "MU-ID", - tag_value: @mommacat.deploy_id, - flags: opts, - dummy_ok: false - ) - - found = found.delete_if { |x| - x.cloud_id.nil? and x.cloudobj.cloud_id.nil? - } + # XXX experimental create behavior for --liveupdate flag, only works on a couple of resource types. Inserting new resources into an old deploy is tricky. + opts = {} + if myservice["#MU_CLOUDCLASS"].cfg_name == "loadbalancer" + opts['classic'] = myservice['classic'] ? true : false + end - if found.size == 0 - if service["#MU_CLOUDCLASS"].cfg_name == "loadbalancer" or - service["#MU_CLOUDCLASS"].cfg_name == "firewall_rule" or - service["#MU_CLOUDCLASS"].cfg_name == "msg_queue" or - service["#MU_CLOUDCLASS"].cfg_name == "server_pool" or - service["#MU_CLOUDCLASS"].cfg_name == "container_cluster" -# XXX only know LBs to be safe, atm - MU.log "#{service["#MU_CLOUDCLASS"].name} #{service['name']} not found, creating", MU::NOTICE + found = MU::MommaCat.findStray(myservice['cloud'], + myservice["#MU_CLOUDCLASS"].cfg_name, + name: myservice['name'], + credentials: myservice['credentials'], + region: myservice['region'], + deploy_id: @mommacat.deploy_id, +# allow_multi: myservice["#MU_CLOUDCLASS"].has_multiple, + tag_key: "MU-ID", + tag_value: @mommacat.deploy_id, + flags: opts, + dummy_ok: false + ) + + found = found.delete_if { |x| + x.cloud_id.nil? and x.cloudobj.cloud_id.nil? + } + + if found.size == 0 + MU.log "#{myservice["#MU_CLOUDCLASS"].name} #{myservice['name']} not found, creating", MU::NOTICE myservice = run_this_method.call + else + real_descriptor = @mommacat.findLitterMate(type: myservice["#MU_CLOUDCLASS"].cfg_name, name: myservice['name'], created_only: true) + + if !real_descriptor + MU.log "Invoking #{run_this_method.to_s} #{myservice['name']} #{myservice['name']}", MU::NOTICE + myservice = run_this_method.call + end +#MU.log "#{myservice["#MU_CLOUDCLASS"].cfg_name} #{myservice['name']}", MU::NOTICE end - else - real_descriptor = @mommacat.findLitterMate(type: service["#MU_CLOUDCLASS"].cfg_name, name: service['name'], created_only: true) - - if !real_descriptor and ( - service["#MU_CLOUDCLASS"].cfg_name == "loadbalancer" or - service["#MU_CLOUDCLASS"].cfg_name == "firewall_rule" or - service["#MU_CLOUDCLASS"].cfg_name == "msg_queue" or - service["#MU_CLOUDCLASS"].cfg_name == "server_pool" or - service["#MU_CLOUDCLASS"].cfg_name == "container_cluster" - ) - MU.log "Invoking #{run_this_method.to_s} #{service['name']} #{service['name']}", MU::NOTICE - myservice = run_this_method.call + + end + rescue ThreadError => e + MU.log "Waiting for threads to complete (#{e.message})", MU::NOTICE + @my_threads.each do |thr| + next if thr.object_id == Thread.current.object_id + thr.join(0.1) + end + @my_threads.reject! { |thr| !thr.alive? } + sleep 10+Random.rand(20) + retry + rescue Exception => e + MU.log e.inspect, MU::ERR, details: e.backtrace if @verbosity != MU::Logger::SILENT + MU::MommaCat.unlockAll + Thread.list.each do |t| + if t.object_id != Thread.current.object_id and t.thread_variable_get("name") != "main_thread" and t.object_id != parent_thread_id and t.thread_variable_get("owned_by_mu") + t.kill end -#MU.log "#{service["#MU_CLOUDCLASS"].cfg_name} #{service['name']}", MU::NOTICE end - - end - rescue Exception => e - MU.log e.inspect, MU::ERR, details: e.backtrace if @verbosity != MU::Logger::SILENT - MU::MommaCat.unlockAll - Thread.list.each do |t| - if t.object_id != Thread.current.object_id and t.thread_variable_get("name") != "main_thread" and t.object_id != parent_thread_id - t.kill + if !@nocleanup + MU::Cleanup.run(MU.deploy_id, verbosity: @verbosity, skipsnapshots: true) + @nocleanup = true # so we don't run this again later end + @main_thread.raise MuError, e.message, e.backtrace end - if !@nocleanup - MU::Cleanup.run(MU.deploy_id, verbosity: @verbosity, skipsnapshots: true) - @nocleanup = true # so we don't run this again later - end - @main_thread.raise MuError, e.message, e.backtrace + MU.purgeGlobals + } + rescue ThreadError => e + MU.log "Waiting for threads to complete (#{e.message})", MU::NOTICE + @my_threads.each do |thr| + next if thr.object_id == Thread.current.object_id + thr.join(0.1) end - MU.purgeGlobals - } + @my_threads.reject! { |thr| !thr.alive? } + sleep 10+Random.rand(20) + retry + end end end diff --git a/modules/mu/groomer.rb b/modules/mu/groomer.rb index 40b3fe2c8..8c1dc6440 100644 --- a/modules/mu/groomer.rb +++ b/modules/mu/groomer.rb @@ -18,12 +18,16 @@ module MU class Groomer # An exception denoting a Groomer run that has failed - class RunError < MuError; + class RunError < StandardError + end + + # An exception denoting nonexistent secret + class MuNoSuchSecret < StandardError end # List of known/supported grooming agents (configuration management tools) def self.supportedGroomers - ["Chef"] + ["Chef", "Ansible"] end # Instance methods that any Groomer plugin must implement @@ -36,17 +40,25 @@ def self.requiredClassMethods [:getSecret, :cleanup, :saveSecret, :deleteSecret] end + class Ansible; + end class Chef; end + # @param groomer [String]: The grooming agent to load. # @return [Class]: The class object implementing this groomer agent def self.loadGroomer(groomer) + return nil if !groomer if !File.size?(MU.myRoot+"/modules/mu/groomers/#{groomer.downcase}.rb") raise MuError, "Requested to use unsupported grooming agent #{groomer}" end + begin require "mu/groomers/#{groomer.downcase}" - myclass = Object.const_get("MU").const_get("Groomer").const_get(groomer) + myclass = Object.const_get("MU").const_get("Groomer").const_get(groomer) + rescue NameError + raise MuError, "No groomer available named '#{groomer}' - valid values (case-sensitive) are: #{MU.supportedGroomers.join(", ")})" + end MU::Groomer.requiredMethods.each { |method| if !myclass.public_instance_methods.include?(method) raise MuError, "MU::Groom::#{groomer} has not implemented required instance method #{method}" diff --git a/modules/mu/groomers/ansible.rb b/modules/mu/groomers/ansible.rb new file mode 100644 index 000000000..a117f698e --- /dev/null +++ b/modules/mu/groomers/ansible.rb @@ -0,0 +1,628 @@ +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +module MU + # Plugins under this namespace serve as interfaces to host configuration + # management tools, like Ansible or Puppet. + class Groomer + # Support for Ansible as a host configuration management layer. + class Ansible + + # Failure to load or create a deploy + class NoAnsibleExecError < MuError; + end + + # Location in which we'll find our Ansible executables. This only applies + # to full-grown Mu masters; minimalist gem installs will have to make do + # with whatever Ansible executables they can find in $PATH. + BINDIR = "/usr/local/python-current/bin" + @@pwfile_semaphore = Mutex.new + + + # @param node [MU::Cloud::Server]: The server object on which we'll be operating + def initialize(node) + @config = node.config + @server = node + @inventory = Inventory.new(node.deploy) + @mu_user = node.deploy.mu_user + @ansible_path = node.deploy.deploy_dir+"/ansible" + @ansible_execs = MU::Groomer::Ansible.ansibleExecDir + + if !@ansible_execs or @ansible_execs.empty? + raise NoAnsibleExecError, "No Ansible executables found in visible paths" + end + + [@ansible_path, @ansible_path+"/roles", @ansible_path+"/vars", @ansible_path+"/group_vars", @ansible_path+"/vaults"].each { |dir| + if !Dir.exist?(dir) + MU.log "Creating #{dir}", MU::DEBUG + Dir.mkdir(dir, 0755) + end + } + MU::Groomer::Ansible.vaultPasswordFile(pwfile: "#{@ansible_path}/.vault_pw") + installRoles + end + + + # Indicate whether our server has been bootstrapped with Ansible + def haveBootstrapped? + @inventory.haveNode?(@server.mu_name) + end + + # @param vault [String]: A repository of secrets to create/save into. + # @param item [String]: The item within the repository to create/save. + # @param data [Hash]: Data to save + # @param permissions [Boolean]: If true, save the secret under the current active deploy (if any), rather than in the global location for this user + # @param deploy_dir [String]: If permissions is +true+, save the secret here + def self.saveSecret(vault: nil, item: nil, data: nil, permissions: false, deploy_dir: nil) + if vault.nil? or vault.empty? or item.nil? or item.empty? + raise MuError, "Must call saveSecret with vault and item names" + end + if vault.match(/\//) or item.match(/\//) #XXX this should just check for all valid dirname/filename chars + raise MuError, "Ansible vault/item names cannot include forward slashes" + end + pwfile = vaultPasswordFile + + + dir = if permissions + if deploy_dir + deploy_dir+"/ansible/vaults/"+vault + elsif MU.mommacat + MU.mommacat.deploy_dir+"/ansible/vaults/"+vault + else + raise "MU::Ansible::Groomer.saveSecret had permissions set to true, but I couldn't find an active deploy directory to save into" + end + else + secret_dir+"/"+vault + end + path = dir+"/"+item + + if !Dir.exist?(dir) + FileUtils.mkdir_p(dir, mode: 0700) + end + + if File.exist?(path) + MU.log "Overwriting existing vault #{vault} item #{item}" + end + File.open(path, File::CREAT|File::RDWR|File::TRUNC, 0600) { |f| + f.write data + } + + cmd = %Q{#{ansibleExecDir}/ansible-vault encrypt #{path} --vault-password-file #{pwfile}} + MU.log cmd + raise MuError, "Failed Ansible command: #{cmd}" if !system(cmd) + end + + # see {MU::Groomer::Ansible.saveSecret} + def saveSecret(vault: @server.mu_name, item: nil, data: nil, permissions: true) + self.class.saveSecret(vault: vault, item: item, data: data, permissions: permissions, deploy_dir: @server.deploy.deploy_dir) + end + + # Retrieve sensitive data, which hopefully we're storing and retrieving + # in a secure fashion. + # @param vault [String]: A repository of secrets to search + # @param item [String]: The item within the repository to retrieve + # @param field [String]: OPTIONAL - A specific field within the item to return. + # @return [Hash] + def self.getSecret(vault: nil, item: nil, field: nil) + if vault.nil? or vault.empty? + raise MuError, "Must call getSecret with at least a vault name" + end + + pwfile = vaultPasswordFile + dir = secret_dir+"/"+vault + if !Dir.exist?(dir) + raise MuNoSuchSecret, "No such vault #{vault}" + end + + data = nil + if item + itempath = dir+"/"+item + if !File.exist?(itempath) + raise MuNoSuchSecret, "No such item #{item} in vault #{vault}" + end + cmd = %Q{#{ansibleExecDir}/ansible-vault view #{itempath} --vault-password-file #{pwfile}} + MU.log cmd + a = `#{cmd}` + # If we happen to have stored recognizeable JSON, return it as parsed, + # which is a behavior we're used to from Chef vault. Otherwise, return + # a String. + begin + data = JSON.parse(a) + if field and data[field] + data = data[field] + end + rescue JSON::ParserError + data = a + end + else + data = [] + Dir.foreach(dir) { |entry| + next if entry == "." or entry == ".." + next if File.directory?(dir+"/"+entry) + data << entry + } + end + + data + end + + # see {MU::Groomer::Ansible.getSecret} + def getSecret(vault: nil, item: nil, field: nil) + self.class.getSecret(vault: vault, item: item, field: field) + end + + # Delete a Ansible data bag / Vault + # @param vault [String]: A repository of secrets to delete + def self.deleteSecret(vault: nil, item: nil) + if vault.nil? or vault.empty? + raise MuError, "Must call deleteSecret with at least a vault name" + end + dir = secret_dir+"/"+vault + if !Dir.exist?(dir) + raise MuNoSuchSecret, "No such vault #{vault}" + end + + data = nil + if item + itempath = dir+"/"+item + if !File.exist?(itempath) + raise MuNoSuchSecret, "No such item #{item} in vault #{vault}" + end + MU.log "Deleting Ansible vault #{vault} item #{item}", MU::NOTICE + File.unlink(itempath) + else + MU.log "Deleting Ansible vault #{vault}", MU::NOTICE + FileUtils.rm_rf(dir) + end + + end + + # see {MU::Groomer::Ansible.deleteSecret} + def deleteSecret(vault: nil, item: nil) + self.class.deleteSecret(vault: vault, item: nil) + end + + # Invoke the Ansible client on the node at the other end of a provided SSH + # session. + # @param purpose [String]: A string describing the purpose of this client run. + # @param max_retries [Integer]: The maximum number of attempts at a successful run to make before giving up. + # @param output [Boolean]: Display Ansible's regular (non-error) output to the console + # @param override_runlist [String]: Use the specified run list instead of the node's configured list + def run(purpose: "Ansible run", update_runlist: true, max_retries: 5, output: true, override_runlist: nil, reboot_first_fail: false, timeout: 1800) + bootstrap + pwfile = MU::Groomer::Ansible.vaultPasswordFile + stashHostSSLCertSecret + + ssh_user = @server.config['ssh_user'] || "root" + + cmd = %Q{cd #{@ansible_path} && #{@ansible_execs}/ansible-playbook -i hosts #{@server.config['name']}.yml --limit=#{@server.mu_name} --vault-password-file #{pwfile} --vault-password-file #{@ansible_path}/.vault_pw -u #{ssh_user}} + + retries = 0 + begin + MU.log cmd + raise MU::Groomer::RunError, "Failed Ansible command: #{cmd}" if !system(cmd) + rescue MU::Groomer::RunError => e + if retries < max_retries + sleep 30 + retries += 1 + MU.log "Failed Ansible run, will retry (#{retries.to_s}/#{max_retries.to_s})", MU::NOTICE, details: cmd + retry + else + raise MuError, "Failed Ansible command: #{cmd}" + end + end + end + + # This is a stub; since Ansible is effectively agentless, this operation + # doesn't have meaning. + def preClean(leave_ours = false) + end + + # This is a stub; since Ansible is effectively agentless, this operation + # doesn't have meaning. + def reinstall + end + + # Bootstrap our server with Ansible- basically, just make sure this node + # is listed in our deployment's Ansible inventory. + def bootstrap + @inventory.add(@server.config['name'], @server.mu_name) + play = { + "hosts" => @server.config['name'] + } + + if @server.config['ssh_user'] != "root" + play["become"] = "yes" + end + + if @server.config['run_list'] and !@server.config['run_list'].empty? + play["roles"] = @server.config['run_list'] + end + + if @server.config['ansible_vars'] + play["vars"] = @server.config['ansible_vars'] + end + + File.open(@ansible_path+"/"+@server.config['name']+".yml", File::CREAT|File::RDWR|File::TRUNC, 0600) { |f| + f.flock(File::LOCK_EX) + f.puts [play].to_yaml + f.flock(File::LOCK_UN) + } + end + + # Synchronize the deployment structure managed by {MU::MommaCat} into some Ansible variables, so that nodes can access this metadata. + # @return [Hash]: The data synchronized. + def saveDeployData + @server.describe(update_cache: true) # Make sure we're fresh + + allvars = { + "mu_deployment" => MU::Config.stripConfig(@server.deploy.deployment), + "mu_service_name" => @config["name"], + "mu_canonical_ip" => @server.canonicalIP, + "mu_admin_email" => $MU_CFG['mu_admin_email'], + "mu_environment" => MU.environment.downcase + } + allvars['mu_deployment']['ssh_public_key'] = @server.deploy.ssh_public_key + + if @server.config['cloud'] == "AWS" + allvars["ec2"] = MU.structToHash(@server.cloud_desc, stringify_keys: true) + end + + if @server.windows? + allvars['windows_admin_username'] = @config['windows_admin_username'] + end + + if !@server.cloud.nil? + allvars["cloudprovider"] = @server.cloud + end + + File.open(@ansible_path+"/vars/main.yml", File::CREAT|File::RDWR|File::TRUNC, 0600) { |f| + f.flock(File::LOCK_EX) + f.puts allvars.to_yaml + f.flock(File::LOCK_UN) + } + + groupvars = allvars.dup + if @server.deploy.original_config.has_key?('parameters') + groupvars["mu_parameters"] = @server.deploy.original_config['parameters'] + end + if !@config['application_attributes'].nil? + groupvars["application_attributes"] = @config['application_attributes'] + end + if !@config['groomer_variables'].nil? + groupvars["mu"] = @config['groomer_variables'] + end + + File.open(@ansible_path+"/group_vars/"+@server.config['name']+".yml", File::CREAT|File::RDWR|File::TRUNC, 0600) { |f| + f.flock(File::LOCK_EX) + f.puts groupvars.to_yaml + f.flock(File::LOCK_UN) + } + + allvars['deployment'] + end + + # Expunge Ansible resources associated with a node. + # @param node [String]: The Mu name of the node in question. + # @param vaults_to_clean [Array]: Some vaults to expunge + # @param noop [Boolean]: Skip actual deletion, just state what we'd do + # @param nodeonly [Boolean]: Just delete the node and its keys, but leave other artifacts + def self.cleanup(node, vaults_to_clean = [], noop = false, nodeonly: false) + deploy = MU::MommaCat.new(MU.deploy_id) + inventory = Inventory.new(deploy) + ansible_path = deploy.deploy_dir+"/ansible" + inventory.remove(node) + end + + # List the Ansible vaults, if any, owned by the specified Mu user + # @param user [String]: The user whose vaults we will list + # @return [Array] + def self.listSecrets(user = MU.mu_user) + path = secret_dir(user) + found = [] + Dir.foreach(path) { |entry| + next if entry == "." or entry == ".." + next if !File.directory?(path+"/"+entry) + found << entry + } + found + end + + # Encrypt a string using +ansible-vault encrypt_string+ and print the + # the results to +STDOUT+. + # @param name [String]: The variable name to use for the string's YAML key + # @param string [String]: The string to encrypt + # @param for_user [String]: Encrypt using the Vault password of the specified Mu user + def self.encryptString(name, string, for_user = nil) + pwfile = vaultPasswordFile + cmd = %Q{#{ansibleExecDir}/ansible-vault} + if !system(cmd, "encrypt_string", string, "--name", name, "--vault-password-file", pwfile) + raise MuError, "Failed Ansible command: #{cmd} encrypt_string --name #{name} --vault-password-file" + end + end + + private + + def self.ansibleExecDir + path = nil + if File.exist?(BINDIR+"/ansible-playbook") + path = BINDIR + else + ENV['PATH'].split(/:/).each { |bindir| + if File.exist?(bindir+"/ansible-playbook") + path = bindir + if !File.exist?(bindir+"/ansible-vault") + MU.log "Found ansible-playbook executable in #{bindir}, but no ansible-vault. Vault functionality will not work!", MU::WARN + end + if !File.exist?(bindir+"/ansible-galaxy") + MU.log "Found ansible-playbook executable in #{bindir}, but no ansible-galaxy. Automatic community role fetch will not work!", MU::WARN + end + break + end + } + end + path + end + + # Get the +.vault_pw+ file for the appropriate user. If it doesn't exist, + # generate one. + def self.vaultPasswordFile(for_user = nil, pwfile: nil) + pwfile ||= secret_dir(for_user)+"/.vault_pw" + @@pwfile_semaphore.synchronize { + if !File.exist?(pwfile) + MU.log "Generating Ansible vault password file at #{pwfile}", MU::DEBUG + File.open(pwfile, File::CREAT|File::RDWR|File::TRUNC, 0400) { |f| + f.write Password.random(12..14) + } + end + } + pwfile + end + + # Figure out where our main stash of secrets is, and make sure it exists + def secret_dir + MU::Groomer::Ansible.secret_dir(@mu_user) + end + + # Figure out where our main stash of secrets is, and make sure it exists + def self.secret_dir(user = MU.mu_user) + path = MU.dataDir(user) + "/ansible-secrets" + Dir.mkdir(path, 0755) if !Dir.exist?(path) + + path + end + + # Make an effort to distinguish an Ansible role from other sorts of + # artifacts, since 'roles' is an awfully generic name for a directory. + # Short of a full, slow syntax check, this is the best we're liable to do. + def isAnsibleRole?(path) + begin + Dir.foreach(path) { |entry| + if File.directory?(path+"/"+entry) and + ["tasks", "vars"].include?(entry) + return true # https://knowyourmeme.com/memes/close-enough + elsif ["metadata.rb", "recipes"].include?(entry) + return false + end + } + rescue Errno::ENOTDIR + end + false + end + + # Find all of the Ansible roles in the various configured Mu repositories + # and + def installRoles + roledir = @ansible_path+"/roles" + + canon_links = {} + + repodirs = [] + + # Make sure we search the global ansible_dir, if any is set + if $MU_CFG and $MU_CFG['ansible_dir'] and !$MU_CFG['ansible_dir'].empty? + if !Dir.exist?($MU_CFG['ansible_dir']) + MU.log "Config lists an Ansible directory at #{$MU_CFG['ansible_dir']}, but I see no such directory", MU::WARN + else + repodirs << $MU_CFG['ansible_dir'] + end + end + + # Hook up any Ansible roles listed in our platform repos + $MU_CFG['repos'].each { |repo| + repo.match(/\/([^\/]+?)(\.git)?$/) + shortname = Regexp.last_match(1) + repodirs << MU.dataDir + "/" + shortname + } + + repodirs.each { |repodir| + ["roles", "ansible/roles"].each { |subdir| + next if !Dir.exist?(repodir+"/"+subdir) + Dir.foreach(repodir+"/"+subdir) { |role| + next if [".", ".."].include?(role) + realpath = repodir+"/"+subdir+"/"+role + link = roledir+"/"+role + + if isAnsibleRole?(realpath) + if !File.exist?(link) + File.symlink(realpath, link) + canon_links[role] = realpath + elsif File.symlink?(link) + cur_target = File.readlink(link) + if cur_target == realpath + canon_links[role] = realpath + elsif !canon_links[role] + File.unlink(link) + File.symlink(realpath, link) + canon_links[role] = realpath + end + end + end + } + } + } + + # Now layer on everything bundled in the main Mu repo + Dir.foreach(MU.myRoot+"/ansible/roles") { |role| + next if [".", ".."].include?(role) + next if File.exist?(roledir+"/"+role) + File.symlink(MU.myRoot+"/ansible/roles/"+role, roledir+"/"+role) + } + + if @server.config['run_list'] + @server.config['run_list'].each { |role| + found = false + if !File.exist?(roledir+"/"+role) + if role.match(/[^\.]\.[^\.]/) and @server.config['groomer_autofetch'] + system(%Q{#{@ansible_execs}/ansible-galaxy}, "--roles-path", roledir, "install", role) + found = true +# XXX check return value + else + canon_links.keys.each { |longrole| + if longrole.match(/\.#{Regexp.quote(role)}$/) + File.symlink(roledir+"/"+longrole, roledir+"/"+role) + found = true + break + end + } + end + else + found = true + end + if !found + raise MuError, "Unable to locate Ansible role #{role}" + end + } + end + end + + # Upload the certificate to a Chef Vault for this node + def stashHostSSLCertSecret + cert, key = @server.deploy.nodeSSLCerts(@server) + certdata = { + "data" => { + "node.crt" => cert.to_pem.chomp!.gsub(/\n/, "\\n"), + "node.key" => key.to_pem.chomp!.gsub(/\n/, "\\n") + } + } + saveSecret(item: "ssl_cert", data: certdata, permissions: true) + + saveSecret(item: "secrets", data: @config['secrets'], permissions: true) if !@config['secrets'].nil? + certdata + end + + # Simple interface for an Ansible inventory file. + class Inventory + + # @param deploy [MU::MommaCat] + def initialize(deploy) + @deploy = deploy + @ansible_path = @deploy.deploy_dir+"/ansible" + if !Dir.exist?(@ansible_path) + Dir.mkdir(@ansible_path, 0755) + end + + @lockfile = File.open(@ansible_path+"/.hosts.lock", File::CREAT|File::RDWR, 0600) + end + + # See if we have a particular node in our inventory. + def haveNode?(name) + lock + read + @inv.each_pair { |group, nodes| + if nodes.include?(name) + unlock + return true + end + } + unlock + false + end + + # Add a node to our Ansible inventory + # @param group [String]: The host group to which the node belongs + # @param name [String]: The hostname or IP of the node + def add(group, name) + if group.nil? or group.empty? or name.nil? or name.empty? + raise MuError, "Ansible::Inventory.add requires both a host group string and a name" + end + lock + read + @inv[group] ||= [] + @inv[group] << name + @inv[group].uniq! + save! + unlock + end + + # Remove a node from our Ansible inventory + # @param name [String]: The hostname or IP of the node + def remove(name) + lock + read + @inv.each_pair { |group, nodes| + nodes.delete(name) + } + save! + unlock + end + + private + + def lock + @lockfile.flock(File::LOCK_EX) + end + + def unlock + @lockfile.flock(File::LOCK_UN) + end + + def save! + @inv ||= {} + + File.open(@ansible_path+"/hosts", File::CREAT|File::RDWR|File::TRUNC, 0600) { |f| + @inv.each_pair { |group, hosts| + next if hosts.size == 0 # don't write empty groups + f.puts "["+group+"]" + f.puts hosts.join("\n") + } + } + end + + def read + @inv = {} + if File.exist?(@ansible_path+"/hosts") + section = nil + File.readlines(@ansible_path+"/hosts").each { |l| + l.chomp! + l.sub!(/#.*/, "") + next if l.empty? + if l.match(/\[(.+?)\]/) + section = Regexp.last_match[1] + @inv[section] ||= [] + else + @inv[section] << l + end + } + end + + @inv + end + + end + + end # class Ansible + end # class Groomer +end # Module Mu diff --git a/modules/mu/groomers/chef.rb b/modules/mu/groomers/chef.rb index 9b1854452..f56864d0d 100644 --- a/modules/mu/groomers/chef.rb +++ b/modules/mu/groomers/chef.rb @@ -20,11 +20,6 @@ class Groomer # Support for Chef as a host configuration management layer. class Chef - # Wrapper class for temporary Exceptions. Gives our internals something - # to inherit that will log a notice message appropriately before - # bubbling up. - class MuNoSuchSecret < StandardError;end - Object.class_eval { def self.const_missing(symbol) if symbol.to_sym == :Chef or symbol.to_sym == :ChefVault @@ -76,7 +71,7 @@ def self.loadChefLib(user = MU.chef_user, env = "dev", mu_user = MU.mu_user) require 'chef/knife/bootstrap_windows_winrm' require 'chef/knife/bootstrap_windows_ssh' ::Chef::Config[:chef_server_url] = "https://#{MU.mu_public_addr}:7443/organizations/#{user}" - if File.exists?("#{Etc.getpwnam(mu_user).dir}/.chef/knife.rb") + if File.exist?("#{Etc.getpwnam(mu_user).dir}/.chef/knife.rb") MU.log "Loading Chef configuration from #{Etc.getpwnam(mu_user).dir}/.chef/knife.rb", MU::DEBUG ::Chef::Config.from_file("#{Etc.getpwnam(mu_user).dir}/.chef/knife.rb") end @@ -223,7 +218,7 @@ def self.deleteSecret(vault: nil, item: nil) loadChefLib raise MuError, "No vault specified, nothing to delete" if vault.nil? MU.log "Deleting #{vault}:#{item} from vaults" - knife_db = nil + knife_cmds = [] if item.nil? knife_cmds << ::Chef::Knife::DataBagDelete.new(['data', 'bag', 'delete', vault]) @@ -266,6 +261,10 @@ def run(purpose: "Chef run", update_runlist: true, max_retries: 5, output: true, chef_node.normal['application_attributes'] = @config['application_attributes'] chef_node.save end + if !@config['groomer_variables'].nil? + chef_node.normal['mu'] = @config['groomer_variables'] + chef_node.save + end if @server.deploy.original_config.has_key?('parameters') MU.log "Setting node:#{@server.mu_name} parameters", MU::DEBUG, details: @server.deploy.original_config['parameters'] chef_node.normal['mu_parameters'] = @server.deploy.original_config['parameters'] @@ -275,7 +274,7 @@ def run(purpose: "Chef run", update_runlist: true, max_retries: 5, output: true, retries = 0 try_upgrade = false - output = [] + output_lines = [] error_signal = "CHEF EXITED BADLY: "+(0...25).map { ('a'..'z').to_a[rand(26)] }.join runstart = nil cmd = nil @@ -299,9 +298,26 @@ def run(purpose: "Chef run", update_runlist: true, max_retries: 5, output: true, Timeout::timeout(timeout) { retval = ssh.exec!(cmd) { |ch, stream, data| puts data - output << data - raise MU::Cloud::BootstrapTempFail if data.match(/REBOOT_SCHEDULED| WARN: Reboot requested:/) - raise MU::Groomer::RunError, output.grep(/ ERROR: /).last if data.match(/#{error_signal}/) + output_lines << data + raise MU::Cloud::BootstrapTempFail if data.match(/REBOOT_SCHEDULED| WARN: Reboot requested:|Rebooting server at a recipe's request|Chef::Exceptions::Reboot/) + if data.match(/#{error_signal}/) + error_msg = "" + clip = false + output_lines.each { |chunk| + chunk.split(/\n/).each { |line| + if !clip and line.match(/^========+/) + clip = true + elsif clip and line.match(/^Running handlers:/) + break + end + + if clip and line.match(/[a-z0-9]/) + error_msg += line.gsub(/\e\[(\d+)m/, '')+"\n" + end + } + } + raise MU::Groomer::RunError, error_msg + end } } else @@ -319,7 +335,7 @@ def run(purpose: "Chef run", update_runlist: true, max_retries: 5, output: true, if try_upgrade pp winrm.run("Invoke-WebRequest -useb https://omnitruck.chef.io/install.ps1 | Invoke-Expression; Install-Project -version:#{MU.chefVersion} -download_directory:$HOME") end - output = [] + output_lines = [] cmd = "c:/opscode/chef/bin/chef-client.bat --color" if override_runlist cmd = cmd + " -o '#{override_runlist}'" @@ -329,20 +345,20 @@ def run(purpose: "Chef run", update_runlist: true, max_retries: 5, output: true, resp = winrm.run(cmd) do |stdout, stderr| if stdout print stdout if output - output << stdout + output_lines << stdout end if stderr MU.log stderr, MU::ERR - output << stderr + output_lines << stderr end end } - if resp.exitcode == 1 and output.join("\n").match(/Chef Client finished/) + if resp.exitcode == 1 and output_lines.join("\n").match(/Chef Client finished/) MU.log "resp.exit code 1" elsif resp.exitcode != 0 - raise MU::Cloud::BootstrapTempFail if resp.exitcode == 35 or output.join("\n").match(/REBOOT_SCHEDULED| WARN: Reboot requested:/) - raise MU::Groomer::RunError, output.slice(output.length-50, output.length).join("") + raise MU::Cloud::BootstrapTempFail if resp.exitcode == 35 or output_lines.join("\n").match(/REBOOT_SCHEDULED| WARN: Reboot requested:|Rebooting server at a recipe's request|Chef::Exceptions::Reboot/) + raise MU::Groomer::RunError, output_lines.slice(output_lines.length-50, output_lines.length).join("") end end rescue MU::Cloud::BootstrapTempFail @@ -402,10 +418,12 @@ def run(purpose: "Chef run", update_runlist: true, max_retries: 5, output: true, sleep 30 retry else + @server.deploy.sendAdminSlack("Chef run '#{purpose}' failed on `#{@server.mu_name}` :crying_cat_face:", msg: e.message) raise MU::Groomer::RunError, "#{@server.mu_name}: Chef run '#{purpose}' failed #{max_retries} times, last error was: #{e.message}" end rescue Exception => e - raise MU::Groomer::RunError, "Caught unexpected #{e.inspect} on #{@server.mu_name} in @groomer.run" + @server.deploy.sendAdminSlack("Chef run '#{purpose}' failed on `#{@server.mu_name}` :crying_cat_face:", msg: e.inspect) + raise MU::Groomer::RunError, "Caught unexpected #{e.inspect} on #{@server.mu_name} in @groomer.run at #{e.backtrace[0]}" end @@ -445,20 +463,33 @@ def preClean(leave_ours = false) end guardfile = "/opt/mu_installed_chef" - ssh = @server.getSSHSession(15) - if leave_ours - MU.log "Expunging pre-existing Chef install on #{@server.mu_name}, if we didn't create it", MU::NOTICE - begin - ssh.exec!(%Q{test -f #{guardfile} || (#{remove_cmd}) ; touch #{guardfile}}) - rescue IOError => e - # TO DO - retry this in a cleaner way - MU.log "Got #{e.inspect} while trying to clean up chef, retrying", MU::NOTICE, details: %Q{test -f #{guardfile} || (#{remove_cmd}) ; touch #{guardfile}} - ssh = @server.getSSHSession(15) - ssh.exec!(%Q{test -f #{guardfile} || (#{remove_cmd}) ; touch #{guardfile}}) + retries = 0 + begin + ssh = @server.getSSHSession(25) + Timeout::timeout(60) { + if leave_ours + MU.log "Expunging pre-existing Chef install on #{@server.mu_name}, if we didn't create it", MU::NOTICE + begin + ssh.exec!(%Q{test -f #{guardfile} || (#{remove_cmd}) ; touch #{guardfile}}) + rescue IOError => e + # TO DO - retry this in a cleaner way + MU.log "Got #{e.inspect} while trying to clean up chef, retrying", MU::NOTICE, details: %Q{test -f #{guardfile} || (#{remove_cmd}) ; touch #{guardfile}} + ssh = @server.getSSHSession(15) + ssh.exec!(%Q{test -f #{guardfile} || (#{remove_cmd}) ; touch #{guardfile}}) + end + else + MU.log "Expunging pre-existing Chef install on #{@server.mu_name}", MU::NOTICE + ssh.exec!(remove_cmd) + end + } + rescue Timeout::Error + if retries < 5 + retries += 1 + sleep 5 + retry + else + raise MuError, "Failed to preClean #{@server.mu_name} after repeated timeouts" end - else - MU.log "Expunging pre-existing Chef install on #{@server.mu_name}", MU::NOTICE - ssh.exec!(remove_cmd) end ssh.close @@ -528,6 +559,7 @@ def reinstall def bootstrap self.class.loadChefLib stashHostSSLCertSecret + splunkVaultInit if !@config['cleaned_chef'] begin leave_ours = @config['scrub_groomer'] ? false : true @@ -659,8 +691,8 @@ def bootstrap end } knifeAddToRunList("role[mu-node]") + knifeAddToRunList("mu-tools::selinux") - splunkVaultInit grantSecretAccess(@server.mu_name, "windows_credentials") if @server.windows? grantSecretAccess(@server.mu_name, "ssl_cert") @@ -674,6 +706,7 @@ def bootstrap run(purpose: "Base configuration", update_runlist: false, max_retries: 20) end ::Chef::Knife.run(['node', 'run_list', 'remove', @server.mu_name, "recipe[mu-tools::updates]"], {}) if !@config['skipinitialupdates'] + ::Chef::Knife.run(['node', 'run_list', 'remove', @server.mu_name, "recipe[mu-tools::selinux]"], {}) # This will deal with Active Directory integration. if !@config['active_directory'].nil? @@ -701,6 +734,11 @@ def bootstrap # @return [Hash]: The data synchronized. def saveDeployData self.class.loadChefLib + if !haveBootstrapped? + MU.log "saveDeployData invoked on #{@server.to_s} before Chef has been bootstrapped!", MU::WARN, details: caller + return + end + @server.describe(update_cache: true) # Make sure we're fresh saveChefMetadata begin @@ -729,10 +767,12 @@ def saveDeployData } end - if chef_node.normal['deployment'] != @server.deploy.deployment + if !@server.deploy.deployment.nil? and + (chef_node.normal['deployment'].nil? or + (chef_node.normal['deployment'].to_h <=> @server.deploy.deployment) != 0 + ) MU.log "Updating node: #{@server.mu_name} deployment attributes", details: @server.deploy.deployment chef_node.normal['deployment'].merge!(@server.deploy.deployment) - chef_node.normal['deployment']['ssh_public_key'] = @server.deploy.ssh_public_key chef_node.save end return chef_node['deployment'] @@ -775,6 +815,15 @@ def self.cleanup(node, vaults_to_clean = [], noop = false, nodeonly: false) rescue Net::HTTPServerException end end + MU.log "knife data bag delete #{node}" + if !noop + knife_cd = ::Chef::Knife::ClientDelete.new(['data', 'bag', 'delete', node]) + knife_cd.config[:yes] = true + begin + knife_cd.run + rescue Net::HTTPServerException + end + end return if nodeonly @@ -783,7 +832,7 @@ def self.cleanup(node, vaults_to_clean = [], noop = false, nodeonly: false) rescue MuNoSuchSecret end ["crt", "key", "csr"].each { |ext| - if File.exists?("#{MU.mySSLDir}/#{node}.#{ext}") + if File.exist?("#{MU.mySSLDir}/#{node}.#{ext}") MU.log "Removing #{MU.mySSLDir}/#{node}.#{ext}" File.unlink("#{MU.mySSLDir}/#{node}.#{ext}") if !noop end @@ -817,6 +866,7 @@ def saveChefMetadata begin chef_node = ::Chef::Node.load(@server.mu_name) rescue Net::HTTPServerException + @server.deploy.sendAdminSlack("Couldn't load Chef metadata on `#{@server.mu_name}` :crying_cat_face:") raise MU::Groomer::RunError, "Couldn't load Chef node #{@server.mu_name}" end @@ -826,6 +876,7 @@ def saveChefMetadata chef_node.normal.app = @config['application_cookbook'] if !@config['application_cookbook'].nil? chef_node.normal["service_name"] = @config["name"] + chef_node.normal["credentials"] = @config["credentials"] chef_node.normal["windows_admin_username"] = @config['windows_admin_username'] chef_node.chef_environment = MU.environment.downcase if @server.config['cloud'] == "AWS" @@ -970,9 +1021,9 @@ def knifeAddToRunList(rl_entry = nil, type="role", ignore_missing: false, multip if multiple.size == 0 multiple = [rl_entry] end - multiple.each { |rl_entry| - if !rl_entry.match(/^role|recipe\[/) - rl_entry = "#{type}[#{rl_entry}]" + multiple.each { |entry| + if !entry.match(/^role|recipe\[/) + entry = "#{type}[#{entry}]" end } @@ -980,27 +1031,27 @@ def knifeAddToRunList(rl_entry = nil, type="role", ignore_missing: false, multip role_list = nil recipe_list = nil missing = false - multiple.each { |rl_entry| - # Rather than argue about whether to expect a bare rl_entry name or - # require rl_entry[rolename], let's just accomodate. - if rl_entry.match(/^role\[(.+?)\]/) - rl_entry_name = Regexp.last_match(1) + multiple.each { |entry| + # Rather than argue about whether to expect a bare entry name or + # require entry[rolename], let's just accomodate. + if entry.match(/^role\[(.+?)\]/) + entry_name = Regexp.last_match(1) if role_list.nil? query=%Q{#{MU::Groomer::Chef.knife} role list}; role_list = %x{#{query}} end - if !role_list.match(/(^|\n)#{rl_entry_name}($|\n)/) - MU.log "Attempting to add non-existent #{rl_entry} to #{@server.mu_name}", MU::WARN + if !role_list.match(/(^|\n)#{entry_name}($|\n)/) + MU.log "Attempting to add non-existent #{entry} to #{@server.mu_name}", MU::WARN missing = true end - elsif rl_entry.match(/^recipe\[(.+?)\]/) - rl_entry_name = Regexp.last_match(1) + elsif entry.match(/^recipe\[(.+?)\]/) + entry_name = Regexp.last_match(1) if recipe_list.nil? query=%Q{#{MU::Groomer::Chef.knife} recipe list}; recipe_list = %x{#{query}} end - if !recipe_list.match(/(^|\n)#{rl_entry_name}($|\n)/) - MU.log "Attempting to add non-existent #{rl_entry} to #{@server.mu_name}", MU::WARN + if !recipe_list.match(/(^|\n)#{entry_name}($|\n)/) + MU.log "Attempting to add non-existent #{entry} to #{@server.mu_name}", MU::WARN missing = true end end diff --git a/modules/mu/logger.rb b/modules/mu/logger.rb index 38532252f..1dfd0aaa5 100644 --- a/modules/mu/logger.rb +++ b/modules/mu/logger.rb @@ -33,24 +33,27 @@ class Logger # Show DEBUG log entries and extra call stack and threading info LOUD = 2.freeze + attr_accessor :verbosity @verbosity = MU::Logger::NORMAL @quiet = false @html = false + @color = true @handle = STDOUT @@log_semaphere = Mutex.new # @param verbosity [Integer]: See {MU::Logger.QUIET}, {MU::Logger.NORMAL}, {MU::Logger.LOUD} # @param html [Boolean]: Enable web-friendly log output. - def initialize(verbosity=MU::Logger::NORMAL, html=false, handle=STDOUT) + def initialize(verbosity=MU::Logger::NORMAL, html=false, handle=STDOUT, color=true) @verbosity = verbosity @html = html @handle = handle + @color = color @summary = [] end attr_reader :summary - attr_accessor :verbosity + attr_accessor :color attr_accessor :quiet attr_accessor :html attr_accessor :handle @@ -65,10 +68,13 @@ def log(msg, details: nil, html: @html, verbosity: @verbosity, - handle: @handle + handle: @handle, + color: @color ) - verbosity = MU::Logger::NORMAL if verbosity.nil? + verbosity ||= @verbosity return if verbosity == MU::Logger::SILENT + return if verbosity < MU::Logger::LOUD and level == DEBUG + return if verbosity < MU::Logger::NORMAL and level == INFO # By which we mean, "get the filename (with the .rb stripped off) which # originated the call to this method. Which, for our purposes, is the @@ -98,12 +104,14 @@ def log(msg, # We get passed literal quoted newlines sometimes, fix 'em. Get Windows' # ugly line feeds too. if !details.nil? + details = details.dup # in case it's frozen or something details.gsub!(/\\n/, "\n") details.gsub!(/(\\r|\r)/, "") end msg = msg.first if msg.is_a?(Array) msg = "" if msg == nil + msg = msg.to_s if !msg.is_a?(String) and msg.respond_to?(:to_s) @@log_semaphere.synchronize { case level @@ -114,9 +122,12 @@ def log(msg, if @html html_out "#{time} - #{caller_name} - #{msg}", "orange" html_out " #{details}" if details - else + elsif color handle.puts "#{time} - #{caller_name} - #{msg}".yellow.on_black handle.puts "#{details}".white.on_black if details + else + handle.puts "#{time} - #{caller_name} - #{msg}" + handle.puts "#{details}" if details end Syslog.log(Syslog::LOG_DEBUG, msg.gsub(/%/, '')) Syslog.log(Syslog::LOG_DEBUG, details.gsub(/%/, '')) if details @@ -125,14 +136,18 @@ def log(msg, if verbosity >= MU::Logger::NORMAL if @html html_out "#{time} - #{caller_name} - #{msg}", "green" - else + elsif color handle.puts "#{time} - #{caller_name} - #{msg}".green.on_black + else + handle.puts "#{time} - #{caller_name} - #{msg}" end if verbosity >= MU::Logger::LOUD if @html html_out " #{details}" - else + elsif color handle.puts "#{details}".white.on_black if details + else + handle.puts "#{details}" if details end end Syslog.log(Syslog::LOG_NOTICE, msg.gsub(/%/, '')) @@ -141,14 +156,18 @@ def log(msg, when NOTICE if @html html_out "#{time} - #{caller_name} - #{msg}", "yellow" - else + elsif color handle.puts "#{time} - #{caller_name} - #{msg}".yellow.on_black + else + handle.puts "#{time} - #{caller_name} - #{msg}" end - if verbosity >= MU::Logger::LOUD + if verbosity >= MU::Logger::QUIET if @html html_out "#{caller_name} - #{msg}" - else + elsif color handle.puts "#{details}".white.on_black if details + else + handle.puts "#{details}" if details end end Syslog.log(Syslog::LOG_NOTICE, msg.gsub(/%/, '')) @@ -156,14 +175,18 @@ def log(msg, when WARN if @html html_out "#{time} - #{caller_name} - #{msg}", "orange" - else + elsif color handle.puts "#{time} - #{caller_name} - #{msg}".light_red.on_black + else + handle.puts "#{time} - #{caller_name} - #{msg}" end - if verbosity >= MU::Logger::LOUD + if verbosity >= MU::Logger::SILENT if @html html_out "#{caller_name} - #{msg}" - else + elsif color handle.puts "#{details}".white.on_black if details + else + handle.puts "#{details}" if details end end Syslog.log(Syslog::LOG_WARNING, msg.gsub(/%/, '')) @@ -172,9 +195,12 @@ def log(msg, if @html html_out "#{time} - #{caller_name} - #{msg}", "red" html_out " #{details}" if details - else + elsif color handle.puts "#{time} - #{caller_name} - #{msg}".red.on_black handle.puts "#{details}".white.on_black if details + else + handle.puts "#{time} - #{caller_name} - #{msg}" + handle.puts "#{details}" if details end Syslog.log(Syslog::LOG_ERR, msg.gsub(/%/, '')) Syslog.log(Syslog::LOG_ERR, details.gsub(/%/, '')) if details @@ -182,9 +208,12 @@ def log(msg, if @html html_out "#{time} - #{caller_name} - #{msg}" html_out " #{details}" if details - else + elsif color handle.puts "#{time} - #{caller_name} - #{msg}".white.on_black handle.puts "#{details}".white.on_black if details + else + handle.puts "#{time} - #{caller_name} - #{msg}" + handle.puts "#{details}" if details end Syslog.log(Syslog::LOG_NOTICE, msg.gsub(/%/, '')) Syslog.log(Syslog::LOG_NOTICE, details.gsub(/%/, '')) if details diff --git a/modules/mu/master.rb b/modules/mu/master.rb index b7d5bb364..75dd7efdd 100644 --- a/modules/mu/master.rb +++ b/modules/mu/master.rb @@ -1,4 +1,3 @@ -#!/usr/local/ruby-current/bin/ruby # Copyright:: Copyright (c) 2014 eGlobalTech, Inc., all rights reserved # # Licensed under the BSD-3 license (the "License"); @@ -23,6 +22,7 @@ class Master require 'fileutils' autoload :Chef, 'mu/master/chef' autoload :LDAP, 'mu/master/ldap' + autoload :SSL, 'mu/master/ssl' # @param users [Hash]: User metadata of the type returned by listUsers def self.printUsersToTerminal(users = MU::Master.listUsers) @@ -169,7 +169,7 @@ def self.storeScratchPadSecret(text) itemname = Password.pronounceable(32) # Make sure this itemname isn't already in use MU::Groomer::Chef.getSecret(vault: "scratchpad", item: itemname) - rescue MU::Groomer::Chef::MuNoSuchSecret + rescue MU::Groomer::MuNoSuchSecret MU::Groomer::Chef.saveSecret(vault: "scratchpad", item: itemname, data: data) return itemname end while true @@ -195,7 +195,7 @@ def self.disk(device, path, size = 50, cryptfile = nil, ramdisk = "ram7") end alias_device = cryptfile ? "/dev/mapper/"+path.gsub(/[^0-9a-z_\-]/i, "_") : realdevice - if !File.exists?(realdevice) + if !File.exist?(realdevice) MU.log "Creating #{path} volume" if MU::Cloud::AWS.hosted? dummy_svr = MU::Cloud::AWS::Server.new( @@ -250,7 +250,7 @@ def self.disk(device, path, size = 50, cryptfile = nil, ramdisk = "ram7") keyfile.close # we can assume that mu-master::init installed cryptsetup-luks - if !File.exists?(alias_device) + if !File.exist?(alias_device) MU.log "Initializing crypto on #{alias_device}", MU::NOTICE %x{/sbin/cryptsetup luksFormat #{realdevice} #{keyfile.path} --batch-mode} %x{/sbin/cryptsetup luksOpen #{realdevice} #{alias_device.gsub(/.*?\/([^\/]+)$/, '\1')} --key-file #{keyfile.path}} @@ -264,7 +264,7 @@ def self.disk(device, path, size = 50, cryptfile = nil, ramdisk = "ram7") %x{/sbin/mkfs.xfs "#{alias_device}"} %x{/usr/sbin/xfs_admin -L "#{path.gsub(/[^0-9a-z_\-]/i, "_")}" "#{alias_device}"} end - Dir.mkdir(path, 0700) if !Dir.exists?(path) # XXX recursive + Dir.mkdir(path, 0700) if !Dir.exist?(path) # XXX recursive %x{/usr/sbin/xfs_info "#{alias_device}" > /dev/null 2>&1} if $?.exitstatus != 0 MU.log "Mounting #{alias_device} to #{path}" @@ -292,7 +292,7 @@ def self.fetchScratchPadSecret(itemname) # Remove Scratchpad entries which have exceeded their maximum age. def self.cleanExpiredScratchpads - return if !$MU_CFG['scratchpad'].has_key?('max_age') or $MU_CFG['scratchpad']['max_age'] < 1 + return if !$MU_CFG['scratchpad'] or !$MU_CFG['scratchpad'].has_key?('max_age') or $MU_CFG['scratchpad']['max_age'] < 1 @scratchpad_semaphore.synchronize { entries = MU::Groomer::Chef.getSecret(vault: "scratchpad") entries.each { |pad| @@ -347,8 +347,8 @@ def self.listUsers ldap_users['mu'] = {} ldap_users['mu']['admin'] = true ldap_users['mu']['non_ldap'] = true - ldap_users.each_pair { |username, data| - key = username.to_s + ldap_users.each_pair { |uname, data| + key = uname.to_s all_user_data[key] = {} userdir = $MU_CFG['installdir']+"/var/users/#{key}" if !Dir.exist?(userdir) @@ -369,6 +369,88 @@ def self.listUsers all_user_data end + + @@kubectl_path = nil + # Locate a working +kubectl+ executable and return its fully-qualified + # path. + def self.kubectl + return @@kubectl_path if @@kubectl_path + + paths = ["/opt/mu/bin"]+ENV['PATH'].split(/:/) + best = nil + best_version = nil + paths.uniq.each { |path| + if File.exist?(path+"/kubectl") + version = %x{#{path}/kubectl version --short --client}.chomp.sub(/.*Client version:\s+v/i, '') + next if !$?.success? + if !best_version or MU.version_sort(best_version, version) > 0 + best_version = version + best = path+"/kubectl" + end + end + } + if !best + MU.log "Failed to find a working kubectl executable in any path", MU::WARN, details: paths.uniq.sort + return nil + else + MU.log "Kubernetes commands will use #{best} (#{best_version})" + end + + @@kubectl_path = best + @@kubectl_path + end + + # Given an array of hashes representing Kubernetes resources, + def self.applyKubernetesResources(name, blobs = [], kubeconfig: nil, outputdir: nil) + use_tmp = false + if !outputdir + require 'tempfile' + use_tmp = true + end + + count = 0 + blobs.each { |blob| + f = nil + blobfile = if use_tmp + f = Tempfile.new("k8s-resource-#{count.to_s}-#{name}") + f.puts blob.to_yaml + f.close + f.path + else + path = outputdir+"/k8s-resource-#{count.to_s}-#{name}" + File.open(path, "w") { |fh| + fh.puts blob.to_yaml + } + path + end + next if !kubectl + done = false + retries = 0 + begin + %x{#{kubectl} --kubeconfig "#{kubeconfig}" get -f #{blobfile} > /dev/null 2>&1} + arg = $?.exitstatus == 0 ? "apply" : "create" + cmd = %Q{#{kubectl} --kubeconfig "#{kubeconfig}" #{arg} -f #{blobfile}} + MU.log "Applying Kubernetes resource #{count.to_s} with kubectl #{arg}", MU::NOTICE, details: cmd + output = %x{#{cmd} 2>&1} + if $?.exitstatus == 0 + MU.log "Kubernetes resource #{count.to_s} #{arg} was successful: #{output}", details: blob.to_yaml + done = true + else + MU.log "Kubernetes resource #{count.to_s} #{arg} failed: #{output}", MU::WARN, details: blob.to_yaml + if retries < 5 + sleep 5 + else + MU.log "Giving up on Kubernetes resource #{count.to_s} #{arg}" + done = true + end + retries += 1 + end + f.unlink if use_tmp + end while !done + count += 1 + } + end + # Update Mu's local cache/metadata for the given user, fixing permissions # and updating stored values. Create a single-user group for the user, as # well. diff --git a/modules/mu/master/chef.rb b/modules/mu/master/chef.rb old mode 100755 new mode 100644 index c9ad1e0af..da88ab056 --- a/modules/mu/master/chef.rb +++ b/modules/mu/master/chef.rb @@ -1,4 +1,3 @@ -#!/usr/local/ruby-current/bin/ruby # Copyright:: Copyright (c) 2014 eGlobalTech, Inc., all rights reserved # # Licensed under the BSD-3 license (the "License"); @@ -112,7 +111,7 @@ def self.createUserClientCfg(user, chef_user) f.puts "chef_server_url 'https://#{$MU_CFG["public_address"]}/organizations/#{chef_user}'" f.puts "validation_client_name '#{chef_user}-validator'" } - if !File.exists?("#{chefdir}/client.rb") or + if !File.exist?("#{chefdir}/client.rb") or File.read("#{chefdir}/client.rb") != File.read("#{chefdir}/client.rb.tmp.#{Process.pid}") File.rename(chefdir+"/client.rb.tmp.#{Process.pid}", chefdir+"/client.rb") FileUtils.chown_R(user, user+".mu-user", Etc.getpwnam(user).dir+"/.chef") @@ -143,7 +142,7 @@ def self.createUserKnifeCfg(user, chef_user) # f.puts "verify_api_cert false" # f.puts "ssl_verify_mode :verify_none" } - if !File.exists?("#{chefdir}/knife.rb") or + if !File.exist?("#{chefdir}/knife.rb") or File.read("#{chefdir}/knife.rb") != File.read("#{chefdir}/knife.rb.tmp.#{Process.pid}") File.rename(chefdir+"/knife.rb.tmp.#{Process.pid}", chefdir+"/knife.rb") FileUtils.chown_R(user, user+".mu-user", Etc.getpwnam(user).dir+"/.chef") diff --git a/modules/mu/master/ldap.rb b/modules/mu/master/ldap.rb old mode 100755 new mode 100644 index a30c099ef..b63edf247 --- a/modules/mu/master/ldap.rb +++ b/modules/mu/master/ldap.rb @@ -1,4 +1,3 @@ -#!/usr/local/ruby-current/bin/ruby # Copyright:: Copyright (c) 2014 eGlobalTech, Inc., all rights reserved # # Licensed under the BSD-3 license (the "License"); diff --git a/modules/mu/master/ssl.rb b/modules/mu/master/ssl.rb new file mode 100644 index 000000000..c53f0e226 --- /dev/null +++ b/modules/mu/master/ssl.rb @@ -0,0 +1,250 @@ +# Copyright:: Copyright (c) 2019 eGlobalTech, Inc., all rights reserved +# +# Licensed under the BSD-3 license (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License in the root of the project or at +# +# http://egt-labs.com/mu/LICENSE.html +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module MU + class Master + # Create and manage our own internal SSL signing authority + class SSL + + # List of Mu services for which we'll generate SSL certs signed by our + # authority. + SERVICES = ["rsyslog", "mommacat", "ldap", "consul", "vault"] + + # Exception class for when we can't find the +openssl+ command + class MuSSLNotFound < MU::MuError;end + +# TODO set file/dir ownerships to honor for_user if we were invoked as root + + # @param for_user [String] + def self.bootstrap(for_user: MU.mu_user) + ssldir = MU.dataDir(for_user)+"/ssl" + Dir.mkdir(ssldir, 0755) if !Dir.exist?(ssldir) + + alt_names = [MU.mu_public_ip, MU.my_private_ip, MU.mu_public_addr, Socket.gethostbyname(Socket.gethostname).first, "localhost", "127.0.0.1"].uniq + alt_names.reject! { |s| s.nil? } + + getCert("Mu_CA", "/CN=#{MU.mu_public_addr}/OU=Mu Server at #{MU.mu_public_addr}/O=eGlobalTech/C=US", sans: alt_names, ca: true) + + SERVICES.each { |service| + getCert(service, "/CN=#{MU.mu_public_addr}/OU=Mu #{service}/O=eGlobalTech/C=US", sans: alt_names) + } + + end + + # @param name [String] + # @param for_user [String] + # @return [OpenSSL::PKey::RSA] + def self.getKey(name, for_user: MU.mu_user, keysize: 4096) + ssldir = MU.dataDir(for_user)+"/ssl" + if !File.exist?(ssldir+"/"+name+".key") + key = OpenSSL::PKey::RSA.new keysize + File.write(ssldir+"/"+name+".key", key) + end + File.chmod(0400, ssldir+"/"+name+".key") + OpenSSL::PKey::RSA.new(File.read(ssldir+"/"+name+".key")) + end + + # @param for_user [String] + # @return [Integer] + def self.incrementCASerial(for_user: MU.mu_user) + ssldir = MU.dataDir(for_user)+"/ssl" + cur = 0 + if File.exist?(ssldir+"/serial") + cur = File.read(ssldir+"/serial").chomp.to_i + end + File.open("#{ssldir}/serial", File::CREAT|File::RDWR, 0600) { |f| + f.flock(File::LOCK_EX) + cur += 1 + f.rewind + f.truncate(0) + f.puts cur + f.flush + f.flock(File::LOCK_UN) + } + cur + end + + + # Given a Certificate Signing Request, sign it with our internal CA and + # write the resulting signed certificate. Only works on local files. + # @param csr_path [String]: The CSR to sign, as a file. + def self.sign(csr_path, sans = [], for_user: MU.mu_user) + certdir = File.dirname(csr_path) + certname = File.basename(csr_path, ".csr") + if File.exist?("#{certdir}/#{certname}.crt") + MU.log "Not re-signing SSL certificate request #{csr_path}, #{certdir}/#{certname}.crt already exists", MU::DEBUG + return + end + MU.log "Signing SSL certificate request #{csr_path} with #{MU.mySSLDir}/Mu_CA.pem" + + begin + csr = OpenSSL::X509::Request.new File.read csr_path + rescue Exception => e + MU.log e.message, MU::ERR, details: File.read(csr_path) + raise e + end + + cakey = getKey("Mu_CA") + cacert = getCert("Mu_CA", ca: true).first + + cert = OpenSSL::X509::Certificate.new + cert.serial = incrementCASerial(for_user: for_user) + cert.version = 0x2 + cert.not_before = Time.now + cert.not_after = Time.now + 180000000 + cert.subject = csr.subject + cert.public_key = csr.public_key + cert.issuer = cacert.subject + ef = OpenSSL::X509::ExtensionFactory.new + ef.issuer_certificate = cacert + ef.subject_certificate = cert + ef.subject_request = csr + if !sans.nil? and !sans.empty? and + !formatSANS(sans).nil? and !formatSANS(sans).empty? + cert.add_extension(ef.create_extension("subjectAltName",formatSANS(sans),false)) + end + cert.add_extension(ef.create_extension("keyUsage","nonRepudiation,digitalSignature,keyEncipherment", false)) + cert.add_extension(ef.create_extension("extendedKeyUsage","clientAuth,serverAuth,codeSigning,emailProtection",false)) + cert.sign cakey, OpenSSL::Digest::SHA256.new + + File.open("#{certdir}/#{certname}.crt", 'w', 0644) { |f| + f.write cert.to_pem + } + + cert + end + + # @param name [String] + # @param cn_str [String] + # @param sans [Array] + # @param ca [Array] + # @param for_user [String] + # @return [OpenSSL::X509::Certificate] + def self.getReq(name, cn_str = nil, sans: [], ca: false, for_user: MU.mu_user) + end + + # @param name [String] + # @param cn_str [String] + # @param sans [Array] + # @param ca [Array] + # @param for_user [String] + # @param pfx [Boolean] + # @return [OpenSSL::X509::Certificate] + def self.getCert(name, cn_str = nil, sans: [], ca: false, for_user: MU.mu_user, pfx: false) + ssldir = MU.dataDir(for_user)+"/ssl" + filename = ca ? "#{ssldir}/#{name}.pem" : "#{ssldir}/#{name}.crt" + keyfile = "#{ssldir}/#{name}.key" + pfxfile = "#{ssldir}/#{name}.pfx" + pfx_cert = nil + + if File.exist?(filename) + pfx_cert = toPfx(filename, keyfile, pfxfile) if pfx + cert = OpenSSL::X509::Certificate.new(File.read(filename)) + return [cert, pfx_cert] + end + + if cn_str.nil? + raise MuError, "Can't generate an SSL cert for #{name} without a CN" + end + + key = getKey(name, for_user: for_user) + +puts cn_str + cn = OpenSSL::X509::Name.parse(cn_str) + + # If we're generating our local CA, we're not really doing a CSR, but + # the operation is close to identical. + csr = if ca + MU.log "Generating Mu CA certificate", MU::NOTICE, details: filename + csr = OpenSSL::X509::Certificate.new + csr.not_before = Time.now + csr.not_after = Time.now + 180000000 + csr + else + MU.log "Generating Mu-signed certificate for #{name}", MU::NOTICE, details: filename + OpenSSL::X509::Request.new + end + + csr.version = 0x2 # by which we mean '3' + csr.subject = cn + csr.public_key = key.public_key + + + # If we're the CA certificate, declare ourselves our own issuer and + # write, instead of going through the rest of the motions. + if ca + csr.issuer = csr.subject + ef = OpenSSL::X509::ExtensionFactory.new + csr.serial = 1 + ef.subject_certificate = csr + ef.issuer_certificate = csr + csr.add_extension(ef.create_extension("subjectAltName",formatSANS(sans),false)) + csr.add_extension(ef.create_extension("basicConstraints", "CA:TRUE", true)) + csr.add_extension(ef.create_extension("keyUsage","keyCertSign, cRLSign", true)) + csr.add_extension(ef.create_extension("subjectKeyIdentifier", "hash", false)) + csr.add_extension(ef.create_extension("authorityKeyIdentifier", "keyid:always", false)) + end + + csr.sign key, OpenSSL::Digest::SHA256.new + + cert = if !ca + File.open("#{ssldir}/#{name}.csr", 'w', 0644) { |f| + f.write csr.to_pem + } + sign("#{ssldir}/#{name}.csr", sans, for_user: for_user) + else + csr + end + + File.open(filename, 'w', 0644) { |f| + f.write cert.to_pem + } + pfx_cert = toPfx(filename, keyfile, pfxfile) if pfx + + if MU.mu_user != "mu" and Process.uid == 0 + owner_uid = Etc.getpwnam(for_user).uid + File.chown(owner_uid, nil, filename) + File.chown(owner_uid, nil, pfxfile) if pfx + end + + + [cert, pfx_cert] + end + + private + + def self.toPfx(certfile, keyfile, pfxfile) + cacert = getCert("Mu_CA", ca: true).first + cert = OpenSSL::X509::Certificate.new(File.read(certfile)) + key = OpenSSL::PKey::RSA.new(File.read(keyfile)) + pfx = OpenSSL::PKCS12.create(nil, nil, key, cert, [cacert], nil, nil, nil, nil) + File.open(pfxfile, 'w', 0644) { |f| + f.write pfx.to_der + } + pfx + end + + def self.formatSANS(sans) + sans.map { |s| + if s.match(/^\d+\.\d+\.\d+\.\d+$/) + "IP:"+s + else + "DNS:"+s + end + }.join(",") + end + + end + end +end diff --git a/modules/mu/mommacat.rb b/modules/mu/mommacat.rb index 547a3e189..46feefd9a 100644 --- a/modules/mu/mommacat.rb +++ b/modules/mu/mommacat.rb @@ -53,21 +53,34 @@ def self.getLitter(deploy_id, set_context_to_me: false, use_cache: true) if deploy_id.nil? or deploy_id.empty? raise MuError, "Cannot fetch a deployment without a deploy_id" end + # XXX this caching may be harmful, causing stale resource objects to stick # around. Have we fixed this? Sort of. Bad entries seem to have no kittens, # so force a reload if we see that. That's probably not the root problem. - @@litter_semaphore.synchronize { - - if !use_cache or !@@litters.has_key?(deploy_id) or @@litters[deploy_id].kittens.nil? or @@litters[deploy_id].kittens.size == 0 - @@litters[deploy_id] = MU::MommaCat.new(deploy_id, set_context_to_me: set_context_to_me) - elsif set_context_to_me - MU::MommaCat.setThreadContext(@@litters[deploy_id]) - end - return @@litters[deploy_id] - } + littercache = nil + begin + @@litter_semaphore.synchronize { + littercache = @@litters.dup + } + rescue ThreadError => e + # already locked by a parent caller and this is a read op, so this is ok + raise e if !e.message.match(/recursive locking/) + littercache = @@litters.dup + end + if !use_cache or littercache[deploy_id].nil? + newlitter = MU::MommaCat.new(deploy_id, set_context_to_me: set_context_to_me) + # This, we have to synchronize, as it's a write + @@litter_semaphore.synchronize { + @@litters[deploy_id] ||= newlitter + } + elsif set_context_to_me + MU::MommaCat.setThreadContext(@@litters[deploy_id]) + end + return @@litters[deploy_id] # MU::MommaCat.new(deploy_id, set_context_to_me: set_context_to_me) end + attr_reader :initializing attr_reader :public_key attr_reader :deploy_secret attr_reader :deployment @@ -155,16 +168,20 @@ def initialize(deploy_id, ssh_private_key: nil, ssh_public_key: nil, nocleanup: false, + appname: nil, + timestamp: nil, set_context_to_me: true, skip_resource_objects: false, no_artifacts: false, deployment_data: {}, + delay_descriptor_load: false, mu_user: Etc.getpwuid(Process.uid).name ) if deploy_id.nil? or deploy_id.empty? raise DeployInitializeError, "MommaCat objects must specify a deploy_id" end set_context_to_me = true if create + @initializing = true @deploy_id = deploy_id @mu_user = mu_user.dup @@ -179,10 +196,11 @@ def initialize(deploy_id, end @kitten_semaphore = Mutex.new @kittens = {} - @original_config = config + @original_config = MU::Config.manxify(config) @nocleanup = nocleanup @secret_semaphore = Mutex.new @notify_semaphore = Mutex.new + @need_deploy_flush = false @node_cert_semaphore = Mutex.new @deployment = deployment_data @deployment['mu_public_ip'] = MU.mu_public_ip @@ -190,16 +208,21 @@ def initialize(deploy_id, @public_key = nil @secrets = Hash.new @secrets['instance_secret'] = Hash.new - @environment = environment @ssh_key_name = ssh_key_name @ssh_private_key = ssh_private_key @ssh_public_key = ssh_public_key @clouds = {} @seed = MU.seed # pass this in @handle = MU.handle # pass this in + @appname = appname + @appname ||= @original_config['name'] if @original_config + @timestamp = timestamp + @environment = environment + if set_context_to_me MU::MommaCat.setThreadContext(self) end + if create and !@no_artifacts if !Dir.exist?(MU.dataDir+"/deployments") MU.log "Creating #{MU.dataDir}/deployments", MU::DEBUG @@ -214,17 +237,20 @@ def initialize(deploy_id, raise DeployInitializeError, "New MommaCat repository requires config hash" end credsets = {} - @appname = @original_config['name'] - MU::Cloud.resource_types.each { |cloudclass, data| - if !@original_config[data[:cfg_plural]].nil? and @original_config[data[:cfg_plural]].size > 0 - @original_config[data[:cfg_plural]].each { |resource| + + MU::Cloud.resource_types.values.each { |attrs| + if !@original_config[attrs[:cfg_plural]].nil? and @original_config[attrs[:cfg_plural]].size > 0 + @original_config[attrs[:cfg_plural]].each { |resource| + credsets[resource['cloud']] ||= [] credsets[resource['cloud']] << resource['credentials'] @clouds[resource['cloud']] = 0 if !@clouds.has_key?(resource['cloud']) @clouds[resource['cloud']] = @clouds[resource['cloud']] + 1 + } end } + @ssh_key_name, @ssh_private_key, @ssh_public_key = self.SSHKey if !File.exist?(deploy_dir+"/private_key") @private_key, @public_key = createDeployKey @@ -243,25 +269,36 @@ def initialize(deploy_id, if set_context_to_me MU::MommaCat.setThreadContext(self) end + save! + end + @appname ||= MU.appname + @timestamp ||= MU.timestamp + @environment ||= MU.environment loadDeploy(set_context_to_me: set_context_to_me) if !deploy_secret.nil? if !authKey(deploy_secret) - raise DeployInitializeError, "Invalid or incorrect deploy key." + raise DeployInitializeError, "Client request did not include a valid deploy authorization secret. Verify that userdata runs correctly?" end end + @@litter_semaphore.synchronize { + @@litters[@deploy_id] ||= self + } + # Initialize a MU::Cloud object for each resource belonging to this # deploy, IF it already exists, which is to say if we're loading an # existing deploy instead of creating a new one. if !create and @deployment and @original_config and !skip_resource_objects + MU::Cloud.resource_types.each_pair { |res_type, attrs| type = attrs[:cfg_plural] if @deployment.has_key?(type) + @deployment[type].each_pair { |res_name, data| orig_cfg = nil if @original_config.has_key?(type) @@ -284,16 +321,30 @@ def initialize(deploy_id, end } end + if orig_cfg.nil? MU.log "Failed to locate original config for #{attrs[:cfg_name]} #{res_name} in #{@deploy_id}", MU::WARN if !["firewall_rules", "databases", "storage_pools", "cache_clusters", "alarms"].include?(type) # XXX shaddap next end + + if orig_cfg['vpc'] and orig_cfg['vpc'].is_a?(Hash) + ref = if orig_cfg['vpc']['id'] and orig_cfg['vpc']['id'].is_a?(Hash) + orig_cfg['vpc']['id']['mommacat'] = self + MU::Config::Ref.get(orig_cfg['vpc']['id']) + else + orig_cfg['vpc']['mommacat'] = self + MU::Config::Ref.get(orig_cfg['vpc']) + end + orig_cfg['vpc'].delete('mommacat') + orig_cfg['vpc'] = ref if ref.kitten + end + begin # Load up MU::Cloud objects for all our kittens in this deploy orig_cfg['environment'] = @environment # not always set in old deploys if attrs[:has_multiples] - data.each_pair { |mu_name, actual_data| - attrs[:interface].new(mommacat: self, kitten_cfg: orig_cfg, mu_name: mu_name) + data.keys.each { |mu_name| + attrs[:interface].new(mommacat: self, kitten_cfg: orig_cfg, mu_name: mu_name, delay_descriptor_load: delay_descriptor_load) } else # XXX hack for old deployments, this can go away some day @@ -315,19 +366,99 @@ def initialize(deploy_id, attrs[:interface].new(mommacat: self, kitten_cfg: orig_cfg, mu_name: data['mu_name'], cloud_id: data['cloud_id']) end rescue Exception => e - MU.log "Failed to load an existing resource of type '#{type}' in #{@deploy_id}: #{e.inspect}", MU::WARN, details: e.backtrace + if e.class != MU::Cloud::MuCloudResourceNotImplemented + MU.log "Failed to load an existing resource of type '#{type}' in #{@deploy_id}: #{e.inspect}", MU::WARN, details: e.backtrace + end end } + end } end + @initializing = false + # XXX this .owned? method may get changed by the Ruby maintainers # if !@@litter_semaphore.owned? -# @@litter_semaphore.synchronize { -# @@litters[@deploy_id] = self -# } -# end + end # end of initialize() + + # List all the cloud providers declared by resources in our deploy. + def cloudsUsed + seen = [] + seen << @original_config['cloud'] if @original_config['cloud'] + MU::Cloud.resource_types.values.each { |attrs| + type = attrs[:cfg_plural] + if @original_config.has_key?(type) + @original_config[type].each { |resource| + seen << resource['cloud'] if resource['cloud'] + } + end + } + seen.uniq + end + + # Assay this deployment for a list of credentials (from mu.yaml) which are + # used. Our Cleanup module can leverage this to skip unnecessary checks. + # @return [Array] + def credsUsed + return [] if !@original_config + seen = [] +# clouds = [] + seen << @original_config['credentials'] if @original_config['credentials'] +# defaultcloud = @original_config['cloud'] + MU::Cloud.resource_types.values.each { |attrs| + type = attrs[:cfg_plural] + if @original_config.has_key?(type) + @original_config[type].each { |resource| + if resource['credentials'] + seen << resource['credentials'] + else + cloudclass = if @original_config['cloud'] + Object.const_get("MU").const_get("Cloud").const_get(@original_config['cloud']) + else + Object.const_get("MU").const_get("Cloud").const_get(MU::Config.defaultCloud) + end + seen << cloudclass.credConfig(name_only: true) + end + } + end + } +# XXX insert default for each cloud provider if not explicitly seen + seen.uniq + end + + # List the regions used by each resource in our deploy. This will just be + # a flat list of strings with no regard to which region belongs with what + # cloud provider- things mostly use this as a lookup table so they can + # safely skip unnecessary regions when creating/cleaning deploy artifacts. + # @return [Array] + def regionsUsed + return [] if !@original_config + regions = [] + regions << @original_config['region'] if @original_config['region'] + MU::Cloud.resource_types.each_pair { |res_type, attrs| + type = attrs[:cfg_plural] + if @original_config.has_key?(type) + @original_config[type].each { |resource| + if resource['cloud'] + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(resource['cloud']) + resclass = Object.const_get("MU").const_get("Cloud").const_get(resource['cloud']).const_get(res_type.to_s) + if resclass.isGlobal? + regions.concat(cloudclass.listRegions) + next + elsif !resource['region'] + regions << cloudclass.myRegion + end + end + if resource['region'] + regions << resource['region'] if resource['region'] + else + end + } + end + } + + regions.uniq end # Tell us the number of first-class resources we've configured, optionally @@ -341,13 +472,13 @@ def numKittens(clouds: [], types: [], negate: false) return 0 if @original_config.nil? if !types.nil? and types.size > 0 types.each { |type| - shortclass, cfg_name, cfg_plural, classname = MU::Cloud.getResourceNames(type) + cfg_plural = MU::Cloud.getResourceNames(type)[2] realtypes << cfg_plural } end count = 0 - MU::Cloud.resource_types.each { |cloudclass, data| + MU::Cloud.resource_types.values.each { |data| next if @original_config[data[:cfg_plural]].nil? next if realtypes.size > 0 and (!negate and !realtypes.include?(data[:cfg_plural])) @original_config[data[:cfg_plural]].each { |resource| @@ -365,13 +496,13 @@ def removeKitten(object) raise MuError, "Nil arguments to removeKitten are not allowed" end @kitten_semaphore.synchronize { - MU::Cloud.resource_types.each_pair { |name, attrs| + MU::Cloud.resource_types.values.each { |attrs| type = attrs[:cfg_plural] next if !@kittens.has_key?(type) tmplitter = @kittens[type].values.dup tmplitter.each { |nodeclass, data| if data.is_a?(Hash) - data.each_pair { |mu_name, obj| + data.keys.each { |mu_name| if data == object @kittens[type][nodeclass].delete(mu_name) return @@ -421,17 +552,19 @@ def addKitten(type, name, object) if !type or !name or !object or !object.mu_name raise MuError, "Nil arguments to addKitten are not allowed (got type: #{type}, name: #{name}, and '#{object}' to add)" end - shortclass, cfg_name, cfg_plural, classname, attrs = MU::Cloud.getResourceNames(type) - type = cfg_plural + + _shortclass, _cfg_name, type, _classname, attrs = MU::Cloud.getResourceNames(type) has_multiples = attrs[:has_multiples] + object.intoDeploy(self) @kitten_semaphore.synchronize { @kittens[type] ||= {} + @kittens[type][object.habitat] ||= {} if has_multiples - @kittens[type][name] ||= {} - @kittens[type][name][object.mu_name] = object + @kittens[type][object.habitat][name] ||= {} + @kittens[type][object.habitat][name][object.mu_name] = object else - @kittens[type][name] = object + @kittens[type][object.habitat][name] = object end } end @@ -458,7 +591,7 @@ def authKey(ciphertext) return false end rescue OpenSSL::PKey::RSAError => e - MU.log e.inspect, MU::ERR + MU.log "Error decrypting provided ciphertext using private key from #{deploy_dir}/private_key: #{e.message}", MU::ERR, details: ciphertext return false end end @@ -506,7 +639,7 @@ def getResourceName(name, max_length: 255, need_unique_string: false, use_unique raise MuError, "Got no argument to MU::MommaCat.getResourceName" end if @appname.nil? or @environment.nil? or @timestamp.nil? or @seed.nil? - MU.log "Missing global deploy variables in thread #{Thread.current.object_id}, using bare name '#{name}' (appname: #{@appname}, environment: #{@environment}, timestamp: #{@timestamp}, seed: #{@seed}", MU::WARN, details: caller + MU.log "getResourceName: Missing global deploy variables in thread #{Thread.current.object_id}, using bare name '#{name}' (appname: #{@appname}, environment: #{@environment}, timestamp: #{@timestamp}, seed: #{@seed}, deploy_id: #{@deploy_id}", MU::WARN, details: caller return name end need_unique_string = false if scrub_mu_isms @@ -597,7 +730,7 @@ def decryptWithDeployKey(ciphertext) def saveNodeSecret(instance_id, raw_secret, type) return if @no_artifacts if instance_id.nil? or instance_id.empty? or raw_secret.nil? or raw_secret.empty? or type.nil? or type.empty? - raise SecretError, "saveNodeSecret requires instance_id, raw_secret, and type args" + raise SecretError, "saveNodeSecret requires instance_id (#{instance_id}), raw_secret (#{raw_secret}), and type (#{type}) args" end MU::MommaCat.lock("deployment-notification") loadDeploy(true) # make sure we're not trampling deployment data @@ -746,14 +879,14 @@ def groomNode(cloud_id, name, type, mu_name: nil, reraise_fail: false, sync_wait kitten.groom rescue Exception => e MU::MommaCat.unlockAll - if e.class.name != "MU::Cloud::AWS::Server::BootstrapTempFail" and !File.exists?(deploy_dir+"/.cleanup."+cloud_id) and !File.exists?(deploy_dir+"/.cleanup") + if e.class.name != "MU::Cloud::AWS::Server::BootstrapTempFail" and !File.exist?(deploy_dir+"/.cleanup."+cloud_id) and !File.exist?(deploy_dir+"/.cleanup") MU.log "Grooming FAILED for #{kitten.mu_name} (#{e.inspect})", MU::ERR, details: e.backtrace -# sendAdminMail("Grooming FAILED for #{kitten.mu_name} on #{MU.appname} \"#{MU.handle}\" (#{MU.deploy_id})", -# msg: e.inspect, -# kitten: kitten, -# data: e.backtrace, -# debug: true -# ) + sendAdminSlack("Grooming FAILED for `#{kitten.mu_name}` with `#{e.message}` :crying_cat_face:", msg: e.backtrace.join("\n")) + sendAdminMail("Grooming FAILED for #{kitten.mu_name} on #{MU.appname} \"#{MU.handle}\" (#{MU.deploy_id})", + msg: e.inspect, + data: e.backtrace, + debug: true + ) raise e if reraise_fail else MU.log "Grooming of #{kitten.mu_name} interrupted by cleanup or planned reboot" @@ -761,7 +894,7 @@ def groomNode(cloud_id, name, type, mu_name: nil, reraise_fail: false, sync_wait return end - if !@deployment['servers'].nil? + if !@deployment['servers'].nil? and !sync_wait syncLitter(@deployment["servers"].keys, triggering_node: kitten) end MU::MommaCat.unlock(cloud_id+"-mommagroom") @@ -770,12 +903,12 @@ def groomNode(cloud_id, name, type, mu_name: nil, reraise_fail: false, sync_wait end MU::MommaCat.getLitter(MU.deploy_id, use_cache: false) MU::MommaCat.syncMonitoringConfig(false) - MU::MommaCat.createStandardTags(cloud_id, region: kitten.config["region"]) MU.log "Grooming complete for '#{name}' mu_name on \"#{MU.handle}\" (#{MU.deploy_id})" - FileUtils.touch("/opt/mu/var/deployments/#{MU.deploy_id}/#{name}_done.txt") + FileUtils.touch(MU.dataDir+"/deployments/#{MU.deploy_id}/#{name}_done.txt") MU::MommaCat.unlockAll if first_groom - sendAdminMail("Grooming complete for '#{name}' (#{mu_name}) on deploy \"#{MU.handle}\" (#{MU.deploy_id})", kitten: kitten) + sendAdminSlack("Grooming complete for #{mu_name} :heart_eyes_cat:") + sendAdminMail("Grooming complete for '#{name}' (#{mu_name}) on deploy \"#{MU.handle}\" (#{MU.deploy_id})") end return end @@ -794,10 +927,10 @@ def SSHKey MU.log "Creating #{ssh_dir}", MU::DEBUG Dir.mkdir(ssh_dir, 0700) if Process.uid == 0 and @mu_user != "mu" - ssh_dir.chown(Etc.getpwnam(@mu_user).uid, Etc.getpwnam(@mu_user).gid) + File.chown(Etc.getpwnam(@mu_user).uid, Etc.getpwnam(@mu_user).gid, ssh_dir) end end - if !File.exists?("#{ssh_dir}/#{@ssh_key_name}") + if !File.exist?("#{ssh_dir}/#{@ssh_key_name}") MU.log "Generating SSH key #{@ssh_key_name}" %x{/usr/bin/ssh-keygen -N "" -f #{ssh_dir}/#{@ssh_key_name}} end @@ -811,6 +944,7 @@ def SSHKey ["servers", "server_pools", "container_clusters"].each { |type| next if @original_config[type].nil? @original_config[type].each { |descriptor| + next if descriptor['cloud'] != "AWS" if descriptor['credentials'] creds_used << descriptor['credentials'] else @@ -836,10 +970,9 @@ def self.unlockAll # in lock() or unlock(). We can't just wrap our iterator block in a # semaphore here, because we're calling another method that uses the # same semaphore. - lock_copy = nil @lock_semaphore.synchronize { delete_list = [] - @locks[Thread.current.object_id].each_pair { |id, fh| + @locks[Thread.current.object_id].keys.each { |id| MU.log "Releasing lock on #{deploy_dir(MU.deploy_id)}/locks/#{id}.lock (thread #{Thread.current.object_id})", MU::DEBUG begin @locks[Thread.current.object_id][id].flock(File::LOCK_UN) @@ -895,7 +1028,7 @@ def self.lock(id, nonblock = false, global = false) else @locks[Thread.current.object_id][id].flock(File::LOCK_EX) end - rescue IOError => e + rescue IOError raise MU::BootstrapTempFail, "Interrupted waiting for lock on thread #{Thread.current.object_id}, probably just a node rebooting as part of a synchronous install" end MU.log "Lock on #{lockdir}/#{id}.lock on thread #{Thread.current.object_id} acquired", MU::DEBUG @@ -940,11 +1073,11 @@ def self.purge(deploy_id) path = File.expand_path(MU.dataDir+"/deployments") if Dir.exist?(path+"/"+deploy_id) unlockAll - MU.log "Purging #{path}/#{deploy_id}" if File.exists?(path+"/"+deploy_id+"/deployment.json") + MU.log "Purging #{path}/#{deploy_id}" if File.exist?(path+"/"+deploy_id+"/deployment.json") FileUtils.rm_rf(path+"/"+deploy_id, :secure => true) end - if File.exists?(path+"/unique_ids") + if File.exist?(path+"/unique_ids") File.open(path+"/unique_ids", File::CREAT|File::RDWR, 0600) { |f| newlines = [] f.flock(File::LOCK_EX) @@ -973,45 +1106,55 @@ def self.cleanTerminatedInstances MU::MommaCat.lock("clean-terminated-instances", false, true) MU.log "Checking for harvested instances in need of cleanup", MU::DEBUG parent_thread_id = Thread.current.object_id - cleanup_threads = [] purged = 0 MU::MommaCat.listDeploys.each { |deploy_id| - next if File.exists?(deploy_dir(deploy_id)+"/.cleanup") + next if File.exist?(deploy_dir(deploy_id)+"/.cleanup") MU.log "Checking for dead wood in #{deploy_id}", MU::DEBUG + need_reload = false @cleanup_threads << Thread.new { MU.dupGlobals(parent_thread_id) - # We can't use cached litter information because we will then try to delete the same node over and over again until we restart the service - deploy = MU::MommaCat.getLitter(deploy_id, set_context_to_me: true, use_cache: false) + deploy = MU::MommaCat.getLitter(deploy_id, set_context_to_me: true) purged_this_deploy = 0 if deploy.kittens.has_key?("servers") - deploy.kittens["servers"].each_pair { |nodeclass, servers| - deletia = [] - servers.each_pair { |mu_name, server| - server.describe - if !server.cloud_id - MU.log "Checking for deletion of #{mu_name}, but unable to fetch its cloud_id", MU::WARN, details: server - elsif !server.active? - next if File.exists?(deploy_dir(deploy_id)+"/.cleanup-"+server.cloud_id) - deletia << mu_name - MU.log "Deleting #{server} (#{nodeclass}), formerly #{server.cloud_id}", MU::NOTICE - begin - server.destroy - deploy.sendAdminMail("Retired terminated node #{mu_name}", kitten: server) - rescue Exception => e - MU.log "Saw #{e.message} while retiring #{mu_name}", MU::ERR, details: e.backtrace - next + deploy.kittens["servers"].values.each { |nodeclasses| + nodeclasses.each_pair { |nodeclass, servers| + deletia = [] + servers.each_pair { |mu_name, server| + server.describe + if !server.cloud_id + MU.log "Checking for presence of #{mu_name}, but unable to fetch its cloud_id", MU::WARN, details: server + elsif !server.active? + next if File.exist?(deploy_dir(deploy_id)+"/.cleanup-"+server.cloud_id) + deletia << mu_name + need_reload = true + MU.log "Cleaning up metadata for #{server} (#{nodeclass}), formerly #{server.cloud_id}, which appears to have been terminated", MU::NOTICE + begin + server.destroy + deploy.sendAdminMail("Retired metadata for terminated node #{mu_name}") + deploy.sendAdminSlack("Retired metadata for terminated node `#{mu_name}`") + rescue Exception => e + MU.log "Saw #{e.message} while retiring #{mu_name}", MU::ERR, details: e.backtrace + next + end + MU.log "Cleanup of metadata for #{server} (#{nodeclass}), formerly #{server.cloud_id} complete", MU::NOTICE + purged = purged + 1 + purged_this_deploy = purged_this_deploy + 1 end - MU.log "Deletion of #{server} (#{nodeclass}), formerly #{server.cloud_id} complete", MU::NOTICE - purged = purged + 1 - purged_this_deploy = purged_this_deploy + 1 + } + deletia.each { |mu_name| + servers.delete(mu_name) + } + if purged_this_deploy > 0 + # XXX some kind of filter (obey sync_siblings on nodes' configs) + deploy.syncLitter(servers.keys) end } - if purged_this_deploy > 0 - # XXX some kind of filter (obey sync_siblings on nodes' configs) - deploy.syncLitter(servers.keys) - end } end + if need_reload + deploy.save! + MU::MommaCat.getLitter(deploy_id, use_cache: false) + end MU.purgeGlobals } } @@ -1029,6 +1172,7 @@ def self.cleanTerminatedInstances MU::MommaCat.unlock("clean-terminated-instances", true) end + @@dummy_cache = {} # Locate a resource that's either a member of another deployment, or of no # deployment at all, and return a {MU::Cloud} object for it. @@ -1045,7 +1189,8 @@ def self.cleanTerminatedInstances # @param dummy_ok [Boolean]: Permit return of a faked {MU::Cloud} object if we don't have enough information to identify a real live one. # @param flags [Hash]: Other cloud or resource type specific options to pass to that resource's find() method # @return [Array] - def self.findStray(cloud, + def self.findStray( + cloud, type, deploy_id: nil, name: nil, @@ -1058,17 +1203,32 @@ def self.findStray(cloud, allow_multi: false, calling_deploy: MU.mommacat, flags: {}, - dummy_ok: false - ) + habitats: [], + dummy_ok: false, + debug: false, + no_deploy_search: false + ) + start = Time.now + callstr = "findStray(cloud: #{cloud}, type: #{type}, deploy_id: #{deploy_id}, calling_deploy: #{calling_deploy.deploy_id if !calling_deploy.nil?}, name: #{name}, cloud_id: #{cloud_id}, tag_key: #{tag_key}, tag_value: #{tag_value}, credentials: #{credentials}, habitats: #{habitats ? habitats.to_s : "[]"}, dummy_ok: #{dummy_ok.to_s}, flags: #{flags.to_s}) from #{caller[0]}" + callstack = caller.dup + return nil if cloud == "CloudFormation" and !cloud_id.nil? + shortclass, _cfg_name, cfg_plural, classname, _attrs = MU::Cloud.getResourceNames(type) + if !MU::Cloud.supportedClouds.include?(cloud) or shortclass.nil? + MU.log "findStray was called with bogus cloud argument '#{cloud}'", MU::WARN, details: callstr + return nil + end + begin + # TODO this is dumb as hell, clean this up.. and while we're at it + # .dup everything so we don't mangle referenced values from the caller deploy_id = deploy_id.to_s if deploy_id.class.to_s == "MU::Config::Tail" name = name.to_s if name.class.to_s == "MU::Config::Tail" cloud_id = cloud_id.to_s if !cloud_id.nil? mu_name = mu_name.to_s if mu_name.class.to_s == "MU::Config::Tail" tag_key = tag_key.to_s if tag_key.class.to_s == "MU::Config::Tail" tag_value = tag_value.to_s if tag_value.class.to_s == "MU::Config::Tail" - shortclass, cfg_name, cfg_plural, classname, attrs = MU::Cloud.getResourceNames(type) + type = cfg_plural resourceclass = MU::Cloud.loadCloudType(cloud, shortclass) cloudclass = Object.const_get("MU").const_get("Cloud").const_get(cloud) @@ -1094,11 +1254,13 @@ def self.findStray(cloud, deploy_id = mu_name.sub(/^(\w+-\w+-\d{10}-[A-Z]{2})-/, '\1') end end - MU.log "Called findStray with cloud: #{cloud}, type: #{type}, deploy_id: #{deploy_id}, calling_deploy: #{calling_deploy.deploy_id if !calling_deploy.nil?}, name: #{name}, cloud_id: #{cloud_id}, tag_key: #{tag_key}, tag_value: #{tag_value}, credentials: #{credentials}", MU::DEBUG, details: flags + loglevel = debug ? MU::NOTICE : MU::DEBUG + + MU.log callstr, loglevel, details: caller # See if the thing we're looking for is a member of the deploy that's # asking after it. - if !deploy_id.nil? and !calling_deploy.nil? and flags.empty? and + if !deploy_id.nil? and !calling_deploy.nil? and calling_deploy.deploy_id == deploy_id and (!name.nil? or !mu_name.nil?) handle = calling_deploy.findLitterMate(type: type, name: name, mu_name: mu_name, cloud_id: cloud_id, credentials: credentials) return [handle] if !handle.nil? @@ -1106,20 +1268,61 @@ def self.findStray(cloud, kittens = {} # Search our other deploys for matching resources - if (deploy_id or name or mu_name or cloud_id)# and flags.empty? + if !no_deploy_search and (deploy_id or name or mu_name or cloud_id) + MU.log "findStray: searching my deployments (#{cfg_plural}, name: #{name}, deploy_id: #{deploy_id}, mu_name: #{mu_name}) - #{sprintf("%.2fs", (Time.now-start))}", loglevel + + # Check our in-memory cache of live deploys before resorting to + # metadata + littercache = nil + # Sometimes we're called inside a locked thread, sometimes not. Deal + # with locking gracefully. + begin + @@litter_semaphore.synchronize { + littercache = @@litters.dup + } + rescue ThreadError => e + raise e if !e.message.match(/recursive locking/) + littercache = @@litters.dup + end + + littercache.each_pair { |cur_deploy, momma| + next if deploy_id and deploy_id != cur_deploy + + straykitten = momma.findLitterMate(type: type, cloud_id: cloud_id, name: name, mu_name: mu_name, credentials: credentials, created_only: true) + if straykitten + MU.log "Found matching kitten #{straykitten.mu_name} in-memory - #{sprintf("%.2fs", (Time.now-start))}", loglevel + # Peace out if we found the exact resource we want + if cloud_id and straykitten.cloud_id.to_s == cloud_id.to_s + return [straykitten] + elsif mu_name and straykitten.mu_name == mu_name + return [straykitten] + else + kittens[straykitten.cloud_id] ||= straykitten + end + end + } + mu_descs = MU::MommaCat.getResourceMetadata(cfg_plural, name: name, deploy_id: deploy_id, mu_name: mu_name) + MU.log "findStray: #{mu_descs.size.to_s} deploys had matches - #{sprintf("%.2fs", (Time.now-start))}", loglevel - mu_descs.each_pair { |deploy_id, matches| + mu_descs.each_pair { |cur_deploy_id, matches| + MU.log "findStray: #{cur_deploy_id} had #{matches.size.to_s} initial matches - #{sprintf("%.2fs", (Time.now-start))}", loglevel next if matches.nil? or matches.size == 0 - momma = MU::MommaCat.getLitter(deploy_id) + + momma = MU::MommaCat.getLitter(cur_deploy_id) + straykitten = nil # If we found exactly one match in this deploy, use its metadata to # guess at resource names we weren't told. - if matches.size == 1 and name.nil? and mu_name.nil? + if matches.size > 1 and cloud_id + MU.log "findStray: attempting to narrow down multiple matches with cloud_id #{cloud_id} - #{sprintf("%.2fs", (Time.now-start))}", loglevel + straykitten = momma.findLitterMate(type: type, cloud_id: cloud_id, credentials: credentials, created_only: true) + elsif matches.size == 1 and name.nil? and mu_name.nil? if cloud_id.nil? straykitten = momma.findLitterMate(type: type, name: matches.first["name"], cloud_id: matches.first["cloud_id"], credentials: credentials) else + MU.log "findStray: fetching single match with cloud_id #{cloud_id} - #{sprintf("%.2fs", (Time.now-start))}", loglevel straykitten = momma.findLitterMate(type: type, name: matches.first["name"], cloud_id: cloud_id, credentials: credentials) end # elsif !flags.nil? and !flags.empty? # XXX eh, maybe later @@ -1140,11 +1343,17 @@ def self.findStray(cloud, end next if straykitten.nil? + straykitten.intoDeploy(momma) - kittens[straykitten.cloud_id] = straykitten + if straykitten.cloud_id.nil? + MU.log "findStray: kitten #{straykitten.mu_name} came back with nil cloud_id", MU::WARN + next + end + + kittens[straykitten.cloud_id] ||= straykitten # Peace out if we found the exact resource we want - if cloud_id and straykitten.cloud_id == cloud_id + if cloud_id and straykitten.cloud_id.to_s == cloud_id.to_s return [straykitten] # ...or if we've validated our one possible match elsif !cloud_id and mu_descs.size == 1 and matches.size == 1 @@ -1154,6 +1363,7 @@ def self.findStray(cloud, end } + # if !mu_descs.nil? and mu_descs.size > 0 and !deploy_id.nil? and !deploy_id.empty? and !mu_descs.first.empty? # MU.log "I found descriptions that might match #{resourceclass.cfg_plural} name: #{name}, deploy_id: #{deploy_id}, mu_name: #{mu_name}, but couldn't isolate my target kitten", MU::WARN, details: caller # puts File.read(deploy_dir(deploy_id)+"/deployment.json") @@ -1171,87 +1381,211 @@ def self.findStray(cloud, matches = [] + found_the_thing = false credlist.each { |creds| - if cloud_id or (tag_key and tag_value) or !flags.empty? - regions = [] - begin - if region - regions << region - else - regions = cloudclass.listRegions(credentials: creds) - end + break if found_the_thing + if cloud_id or (tag_key and tag_value) or !flags.empty? or allow_multi + + regions = begin + region ? [region] : cloudclass.listRegions(credentials: creds) rescue NoMethodError # Not all cloud providers have regions - regions = [""] + [nil] end - if cloud == "Google" and ["vpcs", "firewall_rules"].include?(cfg_plural) + # ..not all resource types care about regions either + if resourceclass.isGlobal? regions = [nil] end - cloud_descs = {} - regions.each { |r| - cloud_descs[r] = resourceclass.find(cloud_id: cloud_id, region: r, tag_key: tag_key, tag_value: tag_value, flags: flags, credentials: creds) - # Stop if you found the thing - if cloud_id and cloud_descs[r] and !cloud_descs[r].empty? - break + # Decide what habitats (accounts/projects/subscriptions) we'll + # search, if applicable for this resource type. + habitats ||= [] + begin + if flags["project"] # backwards-compat + habitats << flags["project"] end - } - regions.each { |r| - next if cloud_descs[r].nil? - cloud_descs[r].each_pair { |kitten_cloud_id, descriptor| - # We already have a MU::Cloud object for this guy, use it - if kittens.has_key?(kitten_cloud_id) - matches << kittens[kitten_cloud_id] - elsif kittens.size == 0 - if !dummy_ok - next + if habitats.empty? + if resourceclass.canLiveIn.include?(nil) + habitats << nil + end + if resourceclass.canLiveIn.include?(:Habitat) + habitats.concat(cloudclass.listProjects(creds)) + end + end + rescue NoMethodError # we only expect this to work on Google atm + end + + if habitats.empty? + habitats << nil + end + habitats.uniq! + + habitat_threads = [] + desc_semaphore = Mutex.new + + cloud_descs = {} + habitats.each { |hab| + begin + habitat_threads.each { |t| t.join(0.1) } + habitat_threads.reject! { |t| t.nil? or !t.status } + sleep 1 if habitat_threads.size > 5 + end while habitat_threads.size > 5 + habitat_threads << Thread.new(hab) { |p| + MU.log "findStray: Searching #{p} (#{habitat_threads.size.to_s} habitat threads running) - #{sprintf("%.2fs", (Time.now-start))}", loglevel + cloud_descs[p] = {} + region_threads = [] + regions.each { |reg| region_threads << Thread.new(reg) { |r| + MU.log "findStray: Searching #{r} in #{p} (#{region_threads.size.to_s} region threads running) - #{sprintf("%.2fs", (Time.now-start))}", loglevel + MU.log "findStray: calling #{classname}.find(cloud_id: #{cloud_id}, region: #{r}, tag_key: #{tag_key}, tag_value: #{tag_value}, flags: #{flags}, credentials: #{creds}, project: #{p}) - #{sprintf("%.2fs", (Time.now-start))}", loglevel +begin + found = resourceclass.find(cloud_id: cloud_id, region: r, tag_key: tag_key, tag_value: tag_value, flags: flags, credentials: creds, habitat: p) + MU.log "findStray: #{found ? found.size.to_s : "nil"} results - #{sprintf("%.2fs", (Time.now-start))}", loglevel +rescue Exception => e +MU.log "#{e.class.name} THREW A FIND EXCEPTION "+e.message, MU::WARN, details: caller +pp e.backtrace +MU.log "#{callstr}", MU::WARN, details: callstack +exit +end + if found + desc_semaphore.synchronize { + cloud_descs[p][r] = found + } + end + # Stop if you found the thing by a specific cloud_id + if cloud_id and found and !found.empty? + found_the_thing = true + Thread.exit end - # If we don't have a MU::Cloud object, manufacture a dummy one. - # Give it a fake name if we have to and have decided that's ok. - if (name.nil? or name.empty?) + } } + begin + region_threads.each { |t| t.join(0.1) } + region_threads.reject! { |t| t.nil? or !t.status } + if region_threads.size > 0 + MU.log "#{region_threads.size.to_s} regions still running in #{p}", loglevel + sleep 3 + end + end while region_threads.size > 0 + } + } + begin + habitat_threads.each { |t| t.join(0.1) } + habitat_threads.reject! { |t| t.nil? or !t.status } + if habitat_threads.size > 0 + MU.log "#{habitat_threads.size.to_s} habitats still running", loglevel + sleep 3 + end + end while habitat_threads.size > 0 + + habitat_threads = [] + habitats.each { |hab| habitat_threads << Thread.new(hab) { |p| + region_threads = [] + regions.each { |reg| region_threads << Thread.new(reg) { |r| + next if cloud_descs[p][r].nil? + cloud_descs[p][r].each_pair { |kitten_cloud_id, descriptor| + + # We already have a MU::Cloud object for this guy, use it + if kittens.has_key?(kitten_cloud_id) + desc_semaphore.synchronize { + matches << kittens[kitten_cloud_id] + } + elsif kittens.size == 0 if !dummy_ok - MU.log "Found cloud provider data for #{cloud} #{type} #{kitten_cloud_id}, but without a name I can't manufacture a proper #{type} object to return", MU::DEBUG, details: caller next - else - if !mu_name.nil? - name = mu_name - elsif !tag_value.nil? - name = tag_value + end + + # If we don't have a MU::Cloud object, manufacture a dummy + # one. Give it a fake name if we have to and have decided + # that's ok. Wild inferences from the cloud descriptor are + # ok to try here. + use_name = if (name.nil? or name.empty?) + if !dummy_ok + nil + elsif !mu_name.nil? + mu_name else - name = kitten_cloud_id + try = nil + [:display_name, :name, (resourceclass.cfg_name+"_name").to_sym].each { |field| + if descriptor.respond_to?(field) and descriptor.send(field).is_a?(String) + try = descriptor.send(field) + break + end + + } + try ||= if !tag_value.nil? + tag_value + else + kitten_cloud_id + end + try end + else + name end - end - cfg = { - "name" => name, - "cloud" => cloud, - "region" => r, - "credentials" => creds - } - # If we can at least find the config from the deploy this will - # belong with, use that, even if it's an ungroomed resource. - if !calling_deploy.nil? and - !calling_deploy.original_config.nil? and - !calling_deploy.original_config[type+"s"].nil? - calling_deploy.original_config[type+"s"].each { |s| - if s["name"] == name - cfg = s.dup - break - end + if use_name.nil? + MU.log "Found cloud provider data for #{cloud} #{type} #{kitten_cloud_id}, but without a name I can't manufacture a proper #{type} object to return - #{sprintf("%.2fs", (Time.now-start))}", loglevel, details: caller + next + end + cfg = { + "name" => use_name, + "cloud" => cloud, + "credentials" => creds } + if !r.nil? and !resourceclass.isGlobal? + cfg["region"] = r + end - matches << resourceclass.new(mommacat: calling_deploy, kitten_cfg: cfg, cloud_id: kitten_cloud_id) - else - matches << resourceclass.new(mu_name: name, kitten_cfg: cfg, cloud_id: kitten_cloud_id.to_s) + if !p.nil? and resourceclass.canLiveIn.include?(:Habitat) + cfg["project"] = p + end + # If we can at least find the config from the deploy this will + # belong with, use that, even if it's an ungroomed resource. + if !calling_deploy.nil? and + !calling_deploy.original_config.nil? and + !calling_deploy.original_config[type+"s"].nil? + calling_deploy.original_config[type+"s"].each { |s| + if s["name"] == use_name + cfg = s.dup + break + end + } + + newkitten = resourceclass.new(mommacat: calling_deploy, kitten_cfg: cfg, cloud_id: kitten_cloud_id) + desc_semaphore.synchronize { + matches << newkitten + } + else + if !@@dummy_cache[cfg_plural] or !@@dummy_cache[cfg_plural][cfg.to_s] + MU.log "findStray: Generating dummy '#{resourceclass.to_s}' cloudobj with name: #{use_name}, cloud_id: #{kitten_cloud_id.to_s} - #{sprintf("%.2fs", (Time.now-start))}", loglevel, details: cfg + resourceclass.new(mu_name: use_name, kitten_cfg: cfg, cloud_id: kitten_cloud_id.to_s, from_cloud_desc: descriptor) + desc_semaphore.synchronize { + @@dummy_cache[cfg_plural] ||= {} + @@dummy_cache[cfg_plural][cfg.to_s] = resourceclass.new(mu_name: use_name, kitten_cfg: cfg, cloud_id: kitten_cloud_id.to_s, from_cloud_desc: descriptor) + MU.log "findStray: Finished generating dummy '#{resourceclass.to_s}' cloudobj - #{sprintf("%.2fs", (Time.now-start))}", loglevel + } + end + desc_semaphore.synchronize { + matches << @@dummy_cache[cfg_plural][cfg.to_s] + } + end end - end + } + } } + MU.log "findStray: tying up #{region_threads.size.to_s} region threads - #{sprintf("%.2fs", (Time.now-start))}", loglevel + region_threads.each { |t| + t.join } + } } + MU.log "findStray: tying up #{habitat_threads.size.to_s} habitat threads - #{sprintf("%.2fs", (Time.now-start))}", loglevel + habitat_threads.each { |t| + t.join } end } rescue Exception => e MU.log e.inspect, MU::ERR, details: e.backtrace end + MU.log "findStray: returning #{matches ? matches.size.to_s : "0"} matches - #{sprintf("%.2fs", (Time.now-start))}", loglevel + matches end @@ -1263,19 +1597,47 @@ def self.findStray(cloud, # @param created_only [Boolean]: Only return the littermate if its cloud_id method returns a value # @param return_all [Boolean]: Return a Hash of matching objects indexed by their mu_name, instead of a single match. Only valid for resource types where has_multiples is true. # @return [MU::Cloud] - def findLitterMate(type: nil, name: nil, mu_name: nil, cloud_id: nil, created_only: false, return_all: false, credentials: nil) + def findLitterMate(type: nil, name: nil, mu_name: nil, cloud_id: nil, created_only: false, return_all: false, credentials: nil, habitat: nil, debug: false, indent: "") shortclass, cfg_name, cfg_plural, classname, attrs = MU::Cloud.getResourceNames(type) type = cfg_plural has_multiples = attrs[:has_multiples] + loglevel = debug ? MU::NOTICE : MU::DEBUG + + argstring = [:type, :name, :mu_name, :cloud_id, :created_only, :credentials, :habitat, :has_multiples].reject { |a| + binding.local_variable_get(a).nil? + }.map { |v| + v.to_s+": "+binding.local_variable_get(v).to_s + }.join(", ") + + # Fun times: if we specified a habitat, which we may also have done by + # its shorthand sibling name, let's... call ourselves first to make sure + # we're fishing for the right thing. + if habitat + MU.log indent+"findLitterMate(#{argstring}): Attempting to resolve habitat name #{habitat}", loglevel + realhabitat = findLitterMate(type: "habitat", name: habitat, debug: debug, credentials: credentials, indent: indent+" ") + if realhabitat and realhabitat.mu_name + MU.log indent+"findLitterMate: Resolved habitat name #{habitat} to #{realhabitat.mu_name}", loglevel, details: [realhabitat.mu_name, realhabitat.cloud_id, realhabitat.config.keys] + habitat = realhabitat.cloud_id + elsif debug + MU.log indent+"findLitterMate(#{argstring}): Failed to resolve habitat name #{habitat}", MU::WARN + end + end + + @kitten_semaphore.synchronize { if !@kittens.has_key?(type) + if debug + MU.log indent+"NO SUCH KEY #{type} findLitterMate(#{argstring})", MU::WARN, details: @kittens.keys + end return nil end - MU.log "findLitterMate(type: #{type}, name: #{name}, mu_name: #{mu_name}, cloud_id: #{cloud_id}, created_only: #{created_only}, credentials: #{credentials}). has_multiples is #{attrs[:has_multiples].to_s}. Caller: #{caller[2]}", MU::DEBUG, details: @kittens.keys.map { |k| k.to_s+": "+@kittens[k].keys.join(", ") } + MU.log indent+"START findLitterMate(#{argstring}), caller: #{caller[2]}", loglevel, details: @kittens[type].keys.map { |hab| hab.to_s+": "+@kittens[type][hab].keys.join(", ") } matches = [] - @kittens[type].each { |sib_class, data| + @kittens[type].each { |habitat_group, sib_classes| + next if habitat and habitat_group != habitat + sib_classes.each_pair { |sib_class, data| virtual_name = nil if !has_multiples and data and !data.is_a?(Hash) and data.config and data.config.is_a?(Hash) and data.config['virtual_name'] and name == data.config['virtual_name'] @@ -1286,14 +1648,13 @@ def findLitterMate(type: nil, name: nil, mu_name: nil, cloud_id: nil, created_on if has_multiples if !name.nil? if return_all + MU.log indent+"MULTI-MATCH RETURN_ALL findLitterMate(#{argstring})", loglevel, details: data.keys return data.dup end if data.size == 1 and (cloud_id.nil? or data.values.first.cloud_id == cloud_id) - obj = data.values.first - return obj + return data.values.first elsif mu_name.nil? and cloud_id.nil? - obj = data.values.first - MU.log "#{@deploy_id}: Found multiple matches in findLitterMate based on #{type}: #{name}, and not enough info to narrow down further. Returning an arbitrary result. Caller: #{caller[2]}", MU::WARN, details: data.keys + MU.log indent+"#{@deploy_id}: Found multiple matches in findLitterMate based on #{type}: #{name}, and not enough info to narrow down further. Returning an arbitrary result. Caller: #{caller[2]}", MU::WARN, details: data.keys return data.values.first end end @@ -1303,20 +1664,35 @@ def findLitterMate(type: nil, name: nil, mu_name: nil, cloud_id: nil, created_on (!credentials.nil? and credentials == obj.credentials) if !created_only or !obj.cloud_id.nil? if return_all + MU.log indent+"MULTI-MATCH RETURN_ALL findLitterMate(#{argstring})", loglevel, details: data.keys return data.dup else + MU.log indent+"MULTI-MATCH findLitterMate(#{argstring})", loglevel, details: data.keys return obj end end end } else - if (name.nil? or sib_class == name or virtual_name == name) and - (cloud_id.nil? or cloud_id == data.cloud_id) and - (credentials.nil? or data.credentials.nil? or credentials == data.credentials) - matches << data if !created_only or !data.cloud_id.nil? + + MU.log indent+"CHECKING AGAINST findLitterMate #{habitat_group}/#{type}/#{sib_class} data.cloud_id: #{data.cloud_id}, data.credentials: #{data.credentials}, sib_class: #{sib_class}, virtual_name: #{virtual_name}", loglevel, details: argstring + + data_cloud_id = data.cloud_id.nil? ? nil : data.cloud_id.to_s + + MU.log indent+"(name.nil? or sib_class == name or virtual_name == name)", loglevel, details: (name.nil? or sib_class == name or virtual_name == name).to_s + MU.log indent+"(cloud_id.nil? or cloud_id[#{cloud_id.class.name}:#{cloud_id.to_s}] == data_cloud_id[#{data_cloud_id.class.name}:#{data_cloud_id}])", loglevel, details: (cloud_id.nil? or cloud_id == data_cloud_id).to_s + MU.log indent+"(credentials.nil? or data.credentials.nil? or credentials[#{credentials.class.name}:#{credentials}] == data.credentials[#{data.credentials.class.name}:#{data.credentials}])", loglevel, details: (credentials.nil? or data.credentials.nil? or credentials == data.credentials).to_s + + if (name.nil? or sib_class == name.to_s or virtual_name == name.to_s) and + (cloud_id.nil? or cloud_id.to_s == data_cloud_id) and + (credentials.nil? or data.credentials.nil? or credentials.to_s == data.credentials.to_s) + if !created_only or !data_cloud_id.nil? + MU.log indent+"SINGLE MATCH findLitterMate(#{argstring})", loglevel, details: [data.mu_name, data_cloud_id, data.config.keys] + matches << data + end end end + } } return matches.first if matches.size == 1 @@ -1325,6 +1701,7 @@ def findLitterMate(type: nil, name: nil, mu_name: nil, cloud_id: nil, created_on end } + MU.log indent+"NO MATCH findLitterMate(#{argstring})", loglevel return nil end @@ -1339,11 +1716,20 @@ def findLitterMate(type: nil, name: nil, mu_name: nil, cloud_id: nil, created_on def notify(type, key, data, mu_name: nil, remove: false, triggering_node: nil, delayed_save: false) return if @no_artifacts MU::MommaCat.lock("deployment-notification") - loadDeploy(true) # make sure we're saving the latest and greatest - have_deploy = true - shortclass, cfg_name, cfg_plural, classname, attrs = MU::Cloud.getResourceNames(type) - type = cfg_plural - has_multiples = attrs[:has_multiples] + + if !@need_deploy_flush or @deployment.nil? or @deployment.empty? + loadDeploy(true) # make sure we're saving the latest and greatest + end + + _shortclass, _cfg_name, cfg_plural, _classname, attrs = MU::Cloud.getResourceNames(type) + has_multiples = false + + # it's not always the case that we're logging data for a legal resource + # type, though that's what we're usually for + if cfg_plural + type = cfg_plural + has_multiples = attrs[:has_multiples] + end if mu_name.nil? if !data.nil? and !data["mu_name"].nil? @@ -1358,15 +1744,21 @@ def notify(type, key, data, mu_name: nil, remove: false, triggering_node: nil, d end end + @need_deploy_flush = true + if !remove if data.nil? MU.log "MU::MommaCat.notify called to modify deployment struct, but no data provided", MU::WARN MU::MommaCat.unlock("deployment-notification") return end - @deployment[type] = {} if @deployment[type].nil? + @notify_semaphore.synchronize { + @deployment[type] ||= {} + } if has_multiples - @deployment[type][key] = {} if @deployment[type][key].nil? + @notify_semaphore.synchronize { + @deployment[type][key] ||= {} + } # fix has_multiples classes that weren't tiered correctly if @deployment[type][key].is_a?(Hash) and @deployment[type][key].has_key?("mu_name") olddata = @deployment[type][key].dup @@ -1394,23 +1786,26 @@ def notify(type, key, data, mu_name: nil, remove: false, triggering_node: nil, d end if have_deploy - if has_multiples - MU.log "Removing @deployment[#{type}][#{key}][#{mu_name}]", MU::DEBUG, details: @deployment[type][key][mu_name] - @deployment[type][key].delete(mu_name) - if @deployment[type][key].size == 0 + @notify_semaphore.synchronize { + if has_multiples + MU.log "Removing @deployment[#{type}][#{key}][#{mu_name}]", MU::DEBUG, details: @deployment[type][key][mu_name] + @deployment[type][key].delete(mu_name) + if @deployment[type][key].size == 0 + @deployment[type].delete(key) + end + else + MU.log "Removing @deployment[#{type}][#{key}]", MU::DEBUG, details: @deployment[type][key] @deployment[type].delete(key) end - else - MU.log "Removing @deployment[#{type}][#{key}]", MU::DEBUG, details: @deployment[type][key] - @deployment[type].delete(key) - end - if @deployment[type].size == 0 - @deployment.delete(type) - end + if @deployment[type].size == 0 + @deployment.delete(type) + end + } end save! if !delayed_save end + MU::MommaCat.unlock("deployment-notification") end @@ -1460,51 +1855,27 @@ def self.createTag(resource = nil, end end - # XXX this belongs in MU::Cloud::AWS - # Tag a resource with all of our standard identifying tags. - # - # @param resource [String]: The cloud provider identifier of the resource to tag - # @param region [String]: The cloud provider region - # @return [void] - def self.createStandardTags(resource = nil, region: MU.curRegion, credentials: nil) - tags = [] - listStandardTags.each_pair { |name, value| - if !value.nil? - tags << {key: name, value: value} - end - } - if MU::Cloud::CloudFormation.emitCloudFormation - return tags - end - - attempts = 0 - begin - MU::Cloud::AWS.ec2(region: region, credentials: credentials).create_tags( - resources: [resource], - tags: tags - ) - rescue Aws::EC2::Errors::ServiceError => e - MU.log "Got #{e.inspect} tagging #{resource} in #{region}, will retry", MU::WARN, details: caller.concat(tags) if attempts > 1 - if attempts < 5 - attempts = attempts + 1 - sleep 15 - retry - else - raise e - end - end - MU.log "Created standard tags for resource #{resource}", MU::DEBUG, details: caller - end - # List the name/value pairs for our mandatory standard set of resource tags, which # should be applied to all taggable cloud provider resources. # @return [Hash] def self.listStandardTags - return { - "MU-ID" => MU.deploy_id, - "MU-APP" => MU.appname, - "MU-ENV" => MU.environment, - "MU-MASTER-IP" => MU.mu_public_ip + return {} if !MU.deploy_id + { + "MU-ID" => MU.deploy_id, + "MU-APP" => MU.appname, + "MU-ENV" => MU.environment, + "MU-MASTER-IP" => MU.mu_public_ip + } + end + # List the name/value pairs for our mandatory standard set of resource tags + # for this deploy. + # @return [Hash] + def listStandardTags + { + "MU-ID" => @deploy_id, + "MU-APP" => @appname, + "MU-ENV" => @environment, + "MU-MASTER-IP" => MU.mu_public_ip } end @@ -1526,7 +1897,7 @@ def self.removeHostFromSSHConfig(node) sshdir = "#{@myhome}/.ssh" sshconf = "#{sshdir}/config" - if File.exists?(sshconf) and File.open(sshconf).read.match(/ #{node} /) + if File.exist?(sshconf) and File.open(sshconf).read.match(/ #{node} /) MU.log "Expunging old #{node} entry from #{sshconf}", MU::DEBUG if !@noop File.open(sshconf, File::CREAT|File::RDWR, 0600) { |f| @@ -1557,8 +1928,7 @@ def self.removeHostFromSSHConfig(node) # @param server [MU::Cloud::Server]: The {MU::Cloud::Server} we'll be setting up. # @param sync_wait [Boolean]: Whether to wait for DNS to fully synchronize before returning. def self.nameKitten(server, sync_wait: false) - node, config, deploydata = server.describe - nat_ssh_key, nat_ssh_user, nat_ssh_host, canonical_addr, ssh_user, ssh_key_name = server.getSSHConfig + node, config, _deploydata = server.describe mu_zone = nil # XXX GCP! @@ -1644,9 +2014,10 @@ def self.addHostToSSHConfig(server, MU.log "Called addHostToSSHConfig without a MU::Cloud::Server object", MU::ERR, details: caller return nil end - begin - nat_ssh_key, nat_ssh_user, nat_ssh_host, canonical_ip, ssh_user, ssh_key_name = server.getSSHConfig - rescue MU::MuError => e + + _nat_ssh_key, nat_ssh_user, nat_ssh_host, canonical_ip, ssh_user, ssh_key_name = begin + server.getSSHConfig + rescue MU::MuError return end @@ -1659,13 +2030,13 @@ def self.addHostToSSHConfig(server, return end if ssh_key_name.nil? or ssh_key_name.empty? - MU.log "Failed to extract canonical_ip for #{ssh_key_name.mu_name} in addHostToSSHConfig", MU::ERR + MU.log "Failed to extract ssh_key_name for #{ssh_key_name.mu_name} in addHostToSSHConfig", MU::ERR return end @ssh_semaphore.synchronize { - if File.exists?(ssh_conf) + if File.exist?(ssh_conf) File.readlines(ssh_conf).each { |line| if line.match(/^Host #{server.mu_name} /) MU.log("Attempt to add duplicate #{ssh_conf} entry for #{server.mu_name}", MU::WARN) @@ -1738,7 +2109,6 @@ def self.removeInstanceFromEtcHosts(node) # @param system_name [String]: The node's local system name # @return [void] def self.addInstanceToEtcHosts(public_ip, chef_name = nil, system_name = nil) - return if !["mu", "root"].include?(MU.mu_user) # XXX cover ipv6 case if public_ip.nil? or !public_ip.match(/^\d+\.\d+\.\d+\.\d+$/) or (chef_name.nil? and system_name.nil?) @@ -1747,6 +2117,19 @@ def self.addInstanceToEtcHosts(public_ip, chef_name = nil, system_name = nil) if chef_name == "localhost" or system_name == "localhost" raise MuError, "Can't set localhost as a name in addInstanceToEtcHosts" end + + if !["mu", "root"].include?(MU.mu_user) + response = nil + begin + response = open("https://127.0.0.1:#{MU.mommaCatPort.to_s}/rest/hosts_add/#{chef_name}/#{public_ip}").read + rescue Errno::ECONNRESET, Errno::ECONNREFUSED + end + if response != "ok" + MU.log "Error adding #{public_ip} to /etc/hosts via MommaCat request", MU::ERR + end + return + end + File.readlines("/etc/hosts").each { |line| if line.match(/^#{public_ip} /) or (chef_name != nil and line.match(/ #{chef_name}(\s|$)/)) or (system_name != nil and line.match(/ #{system_name}(\s|$)/)) MU.log "Ignoring attempt to add duplicate /etc/hosts entry: #{public_ip} #{chef_name} #{system_name}", MU::DEBUG @@ -1761,13 +2144,32 @@ def self.addInstanceToEtcHosts(public_ip, chef_name = nil, system_name = nil) MU.log("Added to /etc/hosts: #{public_ip} #{chef_name} #{system_name}") end - # Send a notification to a deployment's administrators. + + # Send a Slack notification to a deployment's administrators. + # @param subject [String]: The subject line of the message. + # @param msg [String]: The message body. + # @return [void] + def sendAdminSlack(subject, msg: "") + if $MU_CFG['slack'] and $MU_CFG['slack']['webhook'] and + (!$MU_CFG['slack']['skip_environments'] or !$MU_CFG['slack']['skip_environments'].any?{ |s| s.casecmp(MU.environment)==0 }) + require 'slack-notifier' + slack = Slack::Notifier.new $MU_CFG['slack']['webhook'] + + if msg and !msg.empty? + slack.ping "#{MU.appname} \*\"#{MU.handle}\"\* (`#{MU.deploy_id}`) - #{subject}:\n\n```#{msg}\n```", channel: $MU_CFG['slack']['channel'] + else + slack.ping "#{MU.appname} \*\"#{MU.handle}\"\* (`#{MU.deploy_id}`) - #{subject}", channel: $MU_CFG['slack']['channel'] + end + end + end + + # Send an email notification to a deployment's administrators. # @param subject [String]: The subject line of the message. # @param msg [String]: The message body. # @param data [Array]: Supplemental data to add to the message body. # @param debug [Boolean]: If set, will include the full deployment structure and original {MU::Config}-parsed configuration. # @return [void] - def sendAdminMail(subject, msg: msg = "", kitten: nil, data: nil, debug: debug = false) + def sendAdminMail(subject, msg: "", kitten: nil, data: nil, debug: false) require 'net/smtp' if @deployment.nil? MU.log "Can't send admin mail without a loaded deployment", MU::ERR @@ -1875,11 +2277,11 @@ def self.syncMonitoringConfig(blocking = true) MU.dupGlobals(parent_thread_id) realhome = Etc.getpwnam("nagios").dir [@nagios_home, "#{@nagios_home}/.ssh"].each { |dir| - Dir.mkdir(dir, 0711) if !Dir.exists?(dir) + Dir.mkdir(dir, 0711) if !Dir.exist?(dir) File.chown(Etc.getpwnam("nagios").uid, Etc.getpwnam("nagios").gid, dir) } - if realhome != @nagios_home and Dir.exists?(realhome) and !File.symlink?("#{realhome}/.ssh") - File.rename("#{realhome}/.ssh", "#{realhome}/.ssh.#{$$}") if Dir.exists?("#{realhome}/.ssh") + if realhome != @nagios_home and Dir.exist?(realhome) and !File.symlink?("#{realhome}/.ssh") + File.rename("#{realhome}/.ssh", "#{realhome}/.ssh.#{$$}") if Dir.exist?("#{realhome}/.ssh") File.symlink("#{@nagios_home}/.ssh", Etc.getpwnam("nagios").dir+"/.ssh") end MU.log "Updating #{@nagios_home}/.ssh/config..." @@ -1895,18 +2297,12 @@ def self.syncMonitoringConfig(blocking = true) FileUtils.cp("#{@myhome}/.ssh/id_rsa", "#{@nagios_home}/.ssh/id_rsa") File.chown(Etc.getpwnam("nagios").uid, Etc.getpwnam("nagios").gid, "#{@nagios_home}/.ssh/id_rsa") threads = [] - if !MU::Cloud::AWS.isGovCloud? - mu_zone = MU::Cloud::DNSZone.find(cloud_id: "platform-mu").values.first - end -# XXX what if we're in GCP? -# XXX need a MU::Cloud::DNSZone.lookup for bulk lookups -# XXX also grab things like mu_windows_name out of deploy data if we can parent_thread_id = Thread.current.object_id MU::MommaCat.listDeploys.sort.each { |deploy_id| begin # We don't want to use cached litter information here because this is also called by cleanTerminatedInstances. - deploy = MU::MommaCat.getLitter(deploy_id, use_cache: false) + deploy = MU::MommaCat.getLitter(deploy_id) if deploy.ssh_key_name.nil? or deploy.ssh_key_name.empty? MU.log "Failed to extract ssh key name from #{deploy_id} in syncMonitoringConfig", MU::ERR if deploy.kittens.has_key?("servers") next @@ -1914,19 +2310,22 @@ def self.syncMonitoringConfig(blocking = true) FileUtils.cp("#{@myhome}/.ssh/#{deploy.ssh_key_name}", "#{@nagios_home}/.ssh/#{deploy.ssh_key_name}") File.chown(Etc.getpwnam("nagios").uid, Etc.getpwnam("nagios").gid, "#{@nagios_home}/.ssh/#{deploy.ssh_key_name}") if deploy.kittens.has_key?("servers") - deploy.kittens["servers"].each_pair { |nodeclass, nodes| - nodes.each_pair { |mu_name, server| - MU.dupGlobals(parent_thread_id) - threads << Thread.new { - MU::MommaCat.setThreadContext(deploy) - MU.log "Adding #{server.mu_name} to #{@nagios_home}/.ssh/config", MU::DEBUG - MU::MommaCat.addHostToSSHConfig( - server, - ssh_dir: "#{@nagios_home}/.ssh", - ssh_conf: "#{@nagios_home}/.ssh/config.tmp", - ssh_owner: "nagios" - ) - MU.purgeGlobals + deploy.kittens["servers"].values.each { |nodeclasses| + nodeclasses.values.each { |nodes| + nodes.values.each { |server| + next if !server.cloud_desc + MU.dupGlobals(parent_thread_id) + threads << Thread.new { + MU::MommaCat.setThreadContext(deploy) + MU.log "Adding #{server.mu_name} to #{@nagios_home}/.ssh/config", MU::DEBUG + MU::MommaCat.addHostToSSHConfig( + server, + ssh_dir: "#{@nagios_home}/.ssh", + ssh_conf: "#{@nagios_home}/.ssh/config.tmp", + ssh_owner: "nagios" + ) + MU.purgeGlobals + } } } } @@ -1968,10 +2367,10 @@ def self.syncMonitoringConfig(blocking = true) # Return a list of all currently active deploy identifiers. # @return [Array] def self.listDeploys - return [] if !Dir.exists?("#{MU.dataDir}/deployments") + return [] if !Dir.exist?("#{MU.dataDir}/deployments") deploys = [] Dir.entries("#{MU.dataDir}/deployments").reverse_each { |muid| - next if !Dir.exists?("#{MU.dataDir}/deployments/#{muid}") or muid == "." or muid == ".." + next if !Dir.exist?("#{MU.dataDir}/deployments/#{muid}") or muid == "." or muid == ".." deploys << muid } return deploys @@ -1984,7 +2383,7 @@ def self.listAllNodes nodes = Hash.new MU::MommaCat.deploy_struct_semaphore.synchronize { MU::MommaCat.listDeploys.each { |deploy| - if !Dir.exists?(MU::MommaCat.deploy_dir(deploy)) or + if !Dir.exist?(MU::MommaCat.deploy_dir(deploy)) or !File.size?("#{MU::MommaCat.deploy_dir(deploy)}/deployment.json") MU.log "Didn't see deployment metadata for '#{deploy}'", MU::WARN next @@ -2003,7 +2402,7 @@ def self.listAllNodes } end rescue JSON::ParserError => e - MU.log "JSON parse failed on #{MU::MommaCat.deploy_dir(deploy)}/deployment.json", MU::ERR + MU.log "JSON parse failed on #{MU::MommaCat.deploy_dir(deploy)}/deployment.json", MU::ERR, details: e.message end data.flock(File::LOCK_UN) data.close @@ -2069,82 +2468,25 @@ def retrieveWindowsAdminCreds(server) end # Given a Certificate Signing Request, sign it with our internal CA and - # writers the resulting signed certificate. Only works on local files. + # write the resulting signed certificate. Only works on local files. # @param csr_path [String]: The CSR to sign, as a file. def signSSLCert(csr_path, sans = []) - # XXX more sanity here, this feels unsafe - certdir = File.dirname(csr_path) - certname = File.basename(csr_path, ".csr") - if File.exists?("#{certdir}/#{certname}.crt") - MU.log "Not re-signing SSL certificate request #{csr_path}, #{certdir}/#{certname}.crt already exists", MU::WARN - return - end - MU.log "Signing SSL certificate request #{csr_path} with #{MU.mySSLDir}/Mu_CA.pem" - - begin - csr = OpenSSL::X509::Request.new File.read csr_path - rescue Exception => e - MU.log e.message, MU::ERR, details: File.read(csr_path) - raise e - end - key = OpenSSL::PKey::RSA.new File.read "#{certdir}/#{certname}.key" - - # Load up the Mu Certificate Authority - cakey = OpenSSL::PKey::RSA.new File.read "#{MU.mySSLDir}/Mu_CA.key" - cacert = OpenSSL::X509::Certificate.new File.read "#{MU.mySSLDir}/Mu_CA.pem" - cur_serial = 0 - File.open("#{MU.mySSLDir}/serial", File::CREAT|File::RDWR, 0600) { |f| - f.flock(File::LOCK_EX) - cur_serial = f.read.chomp!.to_i - cur_serial = cur_serial + 1 - f.rewind - f.truncate(0) - f.puts cur_serial - f.flush - f.flock(File::LOCK_UN) - } - - # Create a certificate from our CSR, signed by the Mu CA - cert = OpenSSL::X509::Certificate.new - cert.serial = cur_serial - cert.version = 3 - cert.not_before = Time.now - cert.not_after = Time.now + 180000000 - cert.subject = csr.subject - cert.public_key = csr.public_key - cert.issuer = cacert.subject - if !sans.nil? and sans.size > 0 - MU.log "Incorporting Subject Alternative Names: #{sans.join(",")}" - ef = OpenSSL::X509::ExtensionFactory.new - ef.issuer_certificate = cacert -#v3_req_client - ef.subject_certificate = cert - ef.subject_request = csr - cert.add_extension(ef.create_extension("keyUsage","nonRepudiation,digitalSignature,keyEncipherment", false)) - cert.add_extension(ef.create_extension("subjectAltName",sans.join(","),false)) -# XXX only do this if we see the otherName thinger in the san list - cert.add_extension(ef.create_extension("extendedKeyUsage","clientAuth,serverAuth,codeSigning,emailProtection",false)) - end - cert.sign cakey, OpenSSL::Digest::SHA256.new - - open("#{certdir}/#{certname}.crt", 'w', 0644) { |io| - io.write cert.to_pem - } - if MU.mu_user != "mu" - owner_uid = Etc.getpwnam(MU.mu_user).uid - File.chown(owner_uid, nil, "#{certdir}/#{certname}.crt") - end - + MU::Master::SSL.sign(csr_path, sans, for_user: MU.mu_user) end # Make sure deployment data is synchronized to/from each node in the # currently-loaded deployment. - def syncLitter(nodeclasses = [], triggering_node: nil, save_all_only: false) -# XXX take some config logic to decide what nodeclasses to hit -# XXX don't run on triggering node, duh + def syncLitter(nodeclasses = [], triggering_node: nil, save_only: false) +# XXX take some config logic to decide what nodeclasses to hit? like, make +# inferences from dependencies or something? + return if MU.syncLitterThread - return if !Dir.exists?(deploy_dir) + return if !Dir.exist?(deploy_dir) svrs = MU::Cloud.resource_types[:Server][:cfg_plural] # legibility shorthand + if !triggering_node.nil? and nodeclasses.size > 0 + nodeclasses.reject! { |n| n == triggering_node.to_s } + return if nodeclasses.size == 0 + end @kitten_semaphore.synchronize { if @kittens.nil? or @@ -2153,14 +2495,22 @@ def syncLitter(nodeclasses = [], triggering_node: nil, save_all_only: false) return end - MU.log "Updating these siblings in #{@deploy_id}: #{nodeclasses.join(', ')}", MU::DEBUG, details: @kittens[svrs].map { |nodeclass, instance| instance.keys } + + MU.log "Updating these node classes in #{@deploy_id}", MU::DEBUG, details: nodeclasses } update_servers = [] if nodeclasses.nil? or nodeclasses.size == 0 litter = findLitterMate(type: "server", return_all: true) + return if litter.nil? litter.each_pair { |mu_name, node| - next if !triggering_node.nil? and mu_name == triggering_node.mu_name + if !triggering_node.nil? and ( + (triggering_node.is_a?(MU::Cloud::Server) and mu_name == triggering_node.mu_name) or + (triggering_node.is_a?(String) and mu_name == triggering_node) + ) + next + end + if !node.groomer.nil? update_servers << node end @@ -2172,10 +2522,16 @@ def syncLitter(nodeclasses = [], triggering_node: nil, save_all_only: false) litter.merge!(mates) if mates } litter.each_pair { |mu_name, node| - next if !triggering_node.nil? and mu_name == triggering_node.mu_name + if !triggering_node.nil? and ( + (triggering_node.is_a?(MU::Cloud::Server) and mu_name == triggering_node.mu_name) or + (triggering_node.is_a?(String) and mu_name == triggering_node) + ) + next + end + if !node.deploydata or !node.deploydata.keys.include?('nodename') details = node.deploydata ? node.deploydata.keys : nil - MU.log "#{mu_name} deploy data is missing (possibly retired), not syncing it", MU::WARN, details: details + MU.log "#{mu_name} deploy data is missing (possibly retired or mid-bootstrap), so not syncing it", MU::WARN, details: details else update_servers << node end @@ -2183,19 +2539,25 @@ def syncLitter(nodeclasses = [], triggering_node: nil, save_all_only: false) end return if update_servers.size == 0 + MU.log "Updating these nodes in #{@deploy_id}", MU::DEBUG, details: update_servers.map { |n| n.mu_name } + update_servers.each { |node| # Not clear where this pollution comes from, but let's stick a temp # fix in here. - if node.deploydata['nodename'] != node.mu_name + if node.deploydata['nodename'] != node.mu_name and + !node.deploydata['nodename'].nil? and !node.deploydata['nodename'].emty? MU.log "Node #{node.mu_name} had wrong or missing nodename (#{node.deploydata['nodename']}), correcting", MU::WARN node.deploydata['nodename'] = node.mu_name - @deployment[svrs][node.config['name']][node.mu_name]['nodename'] = node.mu_name + if @deployment[svrs] and @deployment[svrs][node.config['name']] and + @deployment[svrs][node.config['name']][node.mu_name] + @deployment[svrs][node.config['name']][node.mu_name]['nodename'] = node.mu_name + end save! end } # Merge everyone's deploydata together - if !save_all_only + if !save_only skip = [] update_servers.each { |node| if node.mu_name.nil? or node.deploydata.nil? or node.config.nil? @@ -2224,7 +2586,7 @@ def syncLitter(nodeclasses = [], triggering_node: nil, save_all_only: false) begin if sibling.config['groom'].nil? or sibling.config['groom'] sibling.groomer.saveDeployData - sibling.groomer.run(purpose: "Synchronizing sibling kittens") if !save_all_only + sibling.groomer.run(purpose: "Synchronizing sibling kittens") if !save_only end rescue MU::Groomer::RunError => e MU.log "Sync of #{sibling.mu_name} failed: #{e.inspect}", MU::WARN @@ -2248,125 +2610,45 @@ def syncLitter(nodeclasses = [], triggering_node: nil, save_all_only: false) # @param poolname [Boolean]: If true, generate certificates for the base name of the server pool of which this node is a member, rather than for the individual node # @param keysize [Integer]: The size of the private key to use when generating this certificate def nodeSSLCerts(resource, poolname = false, keysize = 4096) - nat_ssh_key, nat_ssh_user, nat_ssh_host, canonical_ip, ssh_user, ssh_key_name = resource.getSSHConfig if resource.respond_to?(:getSSHConfig) + _nat_ssh_key, _nat_ssh_user, _nat_ssh_host, canonical_ip, _ssh_user, _ssh_key_name = resource.getSSHConfig if resource.respond_to?(:getSSHConfig) deploy_id = resource.deploy_id || @deploy_id || resource.deploy.deploy_id cert_cn = poolname ? deploy_id + "-" + resource.config['name'].upcase : resource.mu_name - certs = {} results = {} - @node_cert_semaphore.synchronize { - if File.exists?("#{MU.mySSLDir}/#{cert_cn}.crt") and - File.exists?("#{MU.mySSLDir}/#{cert_cn}.key") - ext_cert = OpenSSL::X509::Certificate.new(File.read("#{MU.mySSLDir}/#{cert_cn}.crt")) - if ext_cert.not_after < Time.now - MU.log "Node certificate for #{cert_cn} is expired, regenerating", MU::WARN - ["crt", "key", "csr"].each { |suffix| - if File.exists?("#{MU.mySSLDir}/#{cert_cn}.#{suffix}") - File.unlink("#{MU.mySSLDir}/#{cert_cn}.#{suffix}") - end - } - else - results[cert_cn] = [ - OpenSSL::X509::Certificate.new(File.read("#{MU.mySSLDir}/#{cert_cn}.crt")), - OpenSSL::PKey::RSA.new(File.read("#{MU.mySSLDir}/#{cert_cn}.key")) - ] - end - end + is_windows = (resource.respond_to?(:windows?) and resource.windows?) - if results.size == 0 - certs[cert_cn] = { -# "sans" => ["IP:#{canonical_ip}"], - "cn" => cert_cn - } - if canonical_ip - certs[cert_cn]["sans"] = ["IP:#{canonical_ip}"] + @node_cert_semaphore.synchronize { + MU::Master::SSL.bootstrap + sans = [] + sans << canonical_ip if canonical_ip + # XXX were there other names we wanted to include? + key = MU::Master::SSL.getKey(cert_cn, keysize: keysize) + cert, pfx_cert = MU::Master::SSL.getCert(cert_cn, "/CN=#{cert_cn}/O=Mu/C=US", sans: sans, pfx: is_windows) + results[cert_cn] = [key, cert] + + winrm_cert = nil + if is_windows + winrm_key = MU::Master::SSL.getKey(cert_cn+"-winrm", keysize: keysize) + winrm_cert = MU::Master::SSL.getCert(cert_cn+"-winrm", "/CN=#{resource.config['windows_admin_username']}/O=Mu/C=US", sans: ["otherName:1.3.6.1.4.1.311.20.2.3;UTF8:#{resource.config['windows_admin_username']}@localhost"], pfx: true)[0] + results[cert_cn+"-winrm"] = [winrm_key, winrm_cert] + end + + if resource and resource.config and resource.config['cloud'] + cloudclass = Object.const_get("MU").const_get("Cloud").const_get(resource.config['cloud']) + + cloudclass.writeDeploySecret(@deploy_id, cert.to_pem, cert_cn+".crt", credentials: resource.config['credentials']) + cloudclass.writeDeploySecret(@deploy_id, key.to_pem, cert_cn+".key", credentials: resource.config['credentials']) + if pfx_cert + cloudclass.writeDeploySecret(@deploy_id, pfx_cert.to_der, cert_cn+".pfx", credentials: resource.config['credentials']) end - end - - if [MU::Cloud::Server, MU::Cloud::AWS::Server, MU::Cloud::Google::Server].include?(resource.class) and resource.windows? - if File.exists?("#{MU.mySSLDir}/#{cert_cn}-winrm.crt") and - File.exists?("#{MU.mySSLDir}/#{cert_cn}-winrm.key") - results[cert_cn+"-winrm"] = [File.read("#{MU.mySSLDir}/#{cert_cn}-winrm.crt"), File.read("#{MU.mySSLDir}/#{cert_cn}-winrm.key")] - else - certs[cert_cn+"-winrm"] = { - "sans" => ["otherName:1.3.6.1.4.1.311.20.2.3;UTF8:#{resource.config['windows_admin_username']}@localhost"], - "cn" => resource.config['windows_admin_username'] - } + if winrm_cert + cloudclass.writeDeploySecret(@deploy_id, winrm_cert.to_pem, cert_cn+"-winrm.crt", credentials: resource.config['credentials']) end end - certs.each { |certname, data| - MU.log "Generating SSL certificate #{certname} for #{resource} with key size #{keysize.to_s}" - - # Create and save a key - key = OpenSSL::PKey::RSA.new keysize - if !Dir.exist?(MU.mySSLDir) - Dir.mkdir(MU.mySSLDir, 0700) - end - - open("#{MU.mySSLDir}/#{certname}.key", 'w', 0600) { |io| - io.write key.to_pem - } - # Create a certificate request for this node - csr = OpenSSL::X509::Request.new - csr.version = 3 - csr.subject = OpenSSL::X509::Name.parse "/CN=#{data['cn']}/O=Mu/C=US" - csr.public_key = key.public_key - csr.sign key, OpenSSL::Digest::SHA256.new - open("#{MU.mySSLDir}/#{certname}.csr", 'w', 0644) { |io| - io.write csr.to_pem - } - if MU.chef_user == "mu" - signSSLCert("#{MU.mySSLDir}/#{certname}.csr", data['sans']) - else - deploykey = OpenSSL::PKey::RSA.new(public_key) - deploysecret = Base64.urlsafe_encode64(deploykey.public_encrypt(deploy_secret)) -# XXX things that aren't servers - res_type = "server" - res_type = "server_pool" if !resource.config['basis'].nil? - uri = URI("https://#{MU.mu_public_addr}:2260/") - req = Net::HTTP::Post.new(uri) - req.set_form_data( - "mu_id" => MU.deploy_id, - "mu_resource_name" => resource.config['name'], - "mu_resource_type" => res_type, - "mu_ssl_sign" => "#{MU.mySSLDir}/#{certname}.csr", - "mu_ssl_sans" => data["sans"].join(","), - "mu_user" => MU.mu_user, - "mu_deploy_secret" => deploysecret - ) - http = Net::HTTP.new(uri.hostname, uri.port) - http.ca_file = "/etc/pki/Mu_CA.pem" # XXX why no worky? - http.use_ssl = true - http.verify_mode = OpenSSL::SSL::VERIFY_NONE # XXX this sucks - response = http.request(req) - MU.log "Got error back on signing request for #{MU.mySSLDir}/#{certname}.csr", MU::ERR if response.code != "200" - end - - pfx = nil - cert = OpenSSL::X509::Certificate.new File.read "#{MU.mySSLDir}/#{certname}.crt" - if [MU::Cloud::Server, MU::Cloud::AWS::Server, MU::Cloud::Google::Server].include?(resource.class) and resource.windows? - cacert = OpenSSL::X509::Certificate.new File.read "#{MU.mySSLDir}/Mu_CA.pem" - pfx = OpenSSL::PKCS12.create(nil, nil, key, cert, [cacert], nil, nil, nil, nil) - open("#{MU.mySSLDir}/#{certname}.pfx", 'w', 0644) { |io| - io.write pfx.to_der - } - end - - results[certname] = [cert, key] - - if resource.config['cloud'] == "AWS" - MU::Cloud::AWS.writeDeploySecret(@deploy_id, cert.to_pem, certname+".crt") - MU::Cloud::AWS.writeDeploySecret(@deploy_id, key.to_pem, certname+".key") - if pfx - MU::Cloud::AWS.writeDeploySecret(@deploy_id, pfx.to_der, certname+".pfx") - end -# XXX add google logic, or better yet abstract this method - end - } } results[cert_cn] @@ -2377,80 +2659,129 @@ def deploy_dir MU::MommaCat.deploy_dir(@deploy_id) end - private + # Path to the log file used by the Momma Cat daemon + # @return [String] + def self.daemonLogFile + base = (Process.uid == 0 and !MU.localOnly) ? "/var" : MU.dataDir + "#{base}/log/mu-momma-cat.log" + end - # Check to see whether a given resource name is unique across all - # deployments on this Mu server. We only enforce this for certain classes - # of names. If the name in question is available, add it to our cache of - # said names. See #{MU::MommaCat.getResourceName} - # @param name [String]: The name to attempt to allocate. - # @return [Boolean]: True if allocation was successful. - def allocateUniqueResourceName(name) - raise MuError, "Cannot call allocateUniqueResourceName without an active deployment" if @deploy_id.nil? - path = File.expand_path(MU.dataDir+"/deployments") - File.open(path+"/unique_ids", File::CREAT|File::RDWR, 0600) { |f| - existing = [] - f.flock(File::LOCK_EX) - f.readlines.each { |line| - existing << line.chomp - } - begin - existing.each { |used| - if used.match(/^#{name}:/) - if !used.match(/^#{name}:#{@deploy_id}$/) - MU.log "#{name} is already reserved by another resource on this Mu server.", MU::WARN, details: caller - return false - else - return true - end - end - } - f.puts name+":"+@deploy_id - return true - ensure - f.flock(File::LOCK_UN) - end - } + # Path to the PID file used by the Momma Cat daemon + # @return [String] + def self.daemonPidFile + base = (Process.uid == 0 and !MU.localOnly) ? "/var" : MU.dataDir + "#{base}/run/mommacat.pid" end - ########################################################################### - ########################################################################### - def self.deploy_dir(deploy_id) - raise MuError, "deploy_dir must get a deploy_id if called as class method (from #{caller[0]}; #{caller[1]})" if deploy_id.nil? -# XXX this will blow up if someone sticks MU in / - path = File.expand_path(MU.dataDir+"/deployments") - if !Dir.exist?(path) - MU.log "Creating #{path}", MU::DEBUG - Dir.mkdir(path, 0700) + # Start the Momma Cat daemon and return the exit status of the command used + # @return [Integer] + def self.start + base = (Process.uid == 0 and !MU.localOnly) ? "/var" : MU.dataDir + [base, "#{base}/log", "#{base}/run"].each { |dir| + if !Dir.exist?(dir) + MU.log "Creating #{dir}" + Dir.mkdir(dir) + end + } + return 0 if status + + MU.log "Starting Momma Cat on port #{MU.mommaCatPort}, logging to #{daemonLogFile}, PID file #{daemonPidFile}" + origdir = Dir.getwd + Dir.chdir(MU.myRoot+"/modules") + + # XXX what's the safest way to find the 'bundle' executable in both gem and non-gem installs? + cmd = %Q{bundle exec thin --threaded --daemonize --port #{MU.mommaCatPort} --pid #{daemonPidFile} --log #{daemonLogFile} --ssl --ssl-key-file #{MU.muCfg['ssl']['key']} --ssl-cert-file #{MU.muCfg['ssl']['cert']} --ssl-disable-verify --tag mu-momma-cat -R mommacat.ru start} + MU.log cmd, MU::NOTICE + output = %x{#{cmd}} + Dir.chdir(origdir) + + retries = 0 + begin + sleep 1 + retries += 1 + if retries >= 10 + MU.log "MommaCat failed to start (command was #{cmd})", MU::WARN, details: output + pp caller + return $?.exitstatus + end + end while !status + + if $?.exitstatus != 0 + exit 1 end - path = path+"/"+deploy_id - return path + + return $?.exitstatus end - def self.deploy_exists?(deploy_id) - if deploy_id.nil? or deploy_id.empty? - MU.log "Got nil deploy_id in MU::MommaCat.deploy_exists?", MU::WARN - return + # Return true if the Momma Cat daemon appears to be running + # @return [Boolean] + def self.status + if File.exist?(daemonPidFile) + pid = File.read(daemonPidFile).chomp.to_i + begin + Process.getpgid(pid) + MU.log "Momma Cat running with pid #{pid.to_s}" + return true + rescue Errno::ESRCH + end end - path = File.expand_path(MU.dataDir+"/deployments") - if !Dir.exists?(path) - Dir.mkdir(path, 0700) + MU.log "Momma Cat daemon not running", MU::NOTICE, details: daemonPidFile + false + end + + # Stop the Momma Cat daemon, if it's running + def self.stop + if File.exist?(daemonPidFile) + pid = File.read(daemonPidFile).chomp.to_i + MU.log "Stopping Momma Cat with pid #{pid.to_s}" + Process.kill("INT", pid) + killed = false + begin + Process.getpgid(pid) + sleep 1 + rescue Errno::ESRCH + killed = true + end while killed + MU.log "Momma Cat with pid #{pid.to_s} stopped", MU::DEBUG, details: daemonPidFile + + begin + File.unlink(daemonPidFile) + rescue Errno::ENOENT + end end - deploy_path = File.expand_path(path+"/"+deploy_id) - return Dir.exist?(deploy_path) end + # (Re)start the Momma Cat daemon and return the exit status of the start command + # @return [Integer] + def self.restart + stop + start + end - def createDeployKey - key = OpenSSL::PKey::RSA.generate(4096) - MU.log "Generated deploy key for #{MU.deploy_id}", MU::DEBUG, details: key.public_key.export - return [key.export, key.public_key.export] + # Locate and return the deploy, if any, which matches the provided origin + # description + # @param origin [Hash] + def self.findMatchingDeploy(origin) + MU::MommaCat.listDeploys.each { |deploy_id| + o_path = deploy_dir(deploy_id)+"/origin.json" + next if !File.exist?(o_path) + this_origin = JSON.parse(File.read(o_path)) + if origin == this_origin + MU.log "Deploy #{deploy_id} matches origin hash, loading", details: origin + return MU::MommaCat.new(deploy_id) + end + } + nil end # Synchronize all in-memory information related to this to deployment to # disk. - def save!(triggering_node = nil) - return if @no_artifacts + # @param triggering_node [MU::Cloud::Server]: If we're being triggered by the addition/removal/update of a node, this allows us to notify any sibling or dependent nodes of changes + # @param force [Boolean]: Save even if +no_artifacts+ is set + # @param origin [Hash]: Optional blob of data indicating how this deploy was created + def save!(triggering_node = nil, force: false, origin: nil) + + return if @no_artifacts and !force MU::MommaCat.deploy_struct_semaphore.synchronize { MU.log "Saving deployment #{MU.deploy_id}", MU::DEBUG @@ -2459,6 +2790,12 @@ def save!(triggering_node = nil) Dir.mkdir(deploy_dir, 0700) end + if !origin.nil? + o_file = File.new("#{deploy_dir}/origin.json", File::CREAT|File::TRUNC|File::RDWR, 0600) + o_file.puts JSON.pretty_generate(origin) + o_file.close + end + if !@private_key.nil? privkey = File.new("#{deploy_dir}/private_key", File::CREAT|File::TRUNC|File::RDWR, 0600) privkey.puts @private_key @@ -2474,6 +2811,11 @@ def save!(triggering_node = nil) if !@deployment.nil? and @deployment.size > 0 @deployment['handle'] = MU.handle if @deployment['handle'].nil? and !MU.handle.nil? @deployment['public_key'] = @public_key + @deployment['timestamp'] ||= @timestamp + @deployment['seed'] ||= @seed + @deployment['appname'] ||= @appname + @deployment['handle'] ||= @handle + @deployment['ssh_public_key'] ||= @ssh_public_key if @ssh_public_key begin # XXX doing this to trigger JSON errors before stomping the stored # file... @@ -2483,15 +2825,20 @@ def save!(triggering_node = nil) deploy.flock(File::LOCK_EX) deploy.puts JSON.pretty_generate(@deployment, max_nesting: false) rescue JSON::NestingError => e - raise MuError, e.inspect+"\n\n"+@deployment.to_s + MU.log e.inspect, MU::ERR, details: @deployment + raise MuError, "Got #{e.message} trying to save deployment" + rescue Encoding::UndefinedConversionError => e + MU.log e.inspect, MU::ERR, details: @deployment + raise MuError, "Got #{e.message} at #{e.error_char.dump} (#{e.source_encoding_name} => #{e.destination_encoding_name}) trying to save deployment" end deploy.flock(File::LOCK_UN) deploy.close + @need_deploy_flush = false end if !@original_config.nil? and @original_config.is_a?(Hash) config = File.new("#{deploy_dir}/basket_of_kittens.json", File::CREAT|File::TRUNC|File::RDWR, 0600) - config.puts JSON.pretty_generate(@original_config) + config.puts JSON.pretty_generate(MU::Config.manxify(@original_config)) config.close end @@ -2526,10 +2873,10 @@ def save!(triggering_node = nil) MU.log "Creating #{secretdir}", MU::DEBUG Dir.mkdir(secretdir, 0700) end - @secrets.each_pair { |type, server| - server.each_pair { |server, secret| + @secrets.each_pair { |type, servers| + servers.each_pair { |server, svr_secret| key = File.new("#{secretdir}/#{type}.#{server}", File::CREAT|File::TRUNC|File::RDWR, 0600) - key.puts secret + key.puts svr_secret key.close } } @@ -2537,7 +2884,7 @@ def save!(triggering_node = nil) } # Update groomer copies of this metadata - syncLitter(@deployment['servers'].keys, save_all_only: true) if @deployment.has_key?("servers") + syncLitter(@deployment['servers'].keys, triggering_node: triggering_node, save_only: true) if @deployment.has_key?("servers") end # Find one or more resources by their Mu resource name, and return @@ -2547,21 +2894,112 @@ def save!(triggering_node = nil) # @param type [String]: The type of resource, e.g. "vpc" or "server." # @param name [String]: The Mu resource class, typically the name field of a Basket of Kittens resource declaration. # @param mu_name [String]: The fully-expanded Mu resource name, e.g. MGMT-PROD-2015040115-FR-ADMGMT2 + + private + + # Check to see whether a given resource name is unique across all + # deployments on this Mu server. We only enforce this for certain classes + # of names. If the name in question is available, add it to our cache of + # said names. See #{MU::MommaCat.getResourceName} + # @param name [String]: The name to attempt to allocate. + # @return [Boolean]: True if allocation was successful. + def allocateUniqueResourceName(name) + raise MuError, "Cannot call allocateUniqueResourceName without an active deployment" if @deploy_id.nil? + path = File.expand_path(MU.dataDir+"/deployments") + File.open(path+"/unique_ids", File::CREAT|File::RDWR, 0600) { |f| + existing = [] + f.flock(File::LOCK_EX) + f.readlines.each { |line| + existing << line.chomp + } + begin + existing.each { |used| + if used.match(/^#{name}:/) + if !used.match(/^#{name}:#{@deploy_id}$/) + MU.log "#{name} is already reserved by another resource on this Mu server.", MU::WARN, details: caller + return false + else + return true + end + end + } + f.puts name+":"+@deploy_id + return true + ensure + f.flock(File::LOCK_UN) + end + } + end + + ########################################################################### + ########################################################################### + def self.deploy_dir(deploy_id) + raise MuError, "deploy_dir must get a deploy_id if called as class method (from #{caller[0]}; #{caller[1]})" if deploy_id.nil? +# XXX this will blow up if someone sticks MU in / + path = File.expand_path(MU.dataDir+"/deployments") + if !Dir.exist?(path) + MU.log "Creating #{path}", MU::DEBUG + Dir.mkdir(path, 0700) + end + path = path+"/"+deploy_id + return path + end + + def self.deploy_exists?(deploy_id) + if deploy_id.nil? or deploy_id.empty? + MU.log "Got nil deploy_id in MU::MommaCat.deploy_exists?", MU::WARN + return + end + path = File.expand_path(MU.dataDir+"/deployments") + if !Dir.exist?(path) + Dir.mkdir(path, 0700) + end + deploy_path = File.expand_path(path+"/"+deploy_id) + return Dir.exist?(deploy_path) + end + + + def createDeployKey + key = OpenSSL::PKey::RSA.generate(4096) + MU.log "Generated deploy key for #{MU.deploy_id}", MU::DEBUG, details: key.public_key.export + return [key.export, key.public_key.export] + end + # @param deploy_id [String]: The deployment to search. Will search all deployments if not specified. # @return [Hash,Array] def self.getResourceMetadata(type, name: nil, deploy_id: nil, use_cache: true, mu_name: nil) if type.nil? raise MuError, "Can't call getResourceMetadata without a type argument" end - shortclass, cfg_name, cfg_plural, classname = MU::Cloud.getResourceNames(type) - type = cfg_plural + _shortclass, _cfg_name, type, _classname = MU::Cloud.getResourceNames(type) + + # first, check our in-memory deploys, which may or may not have been + # written to disk yet. + littercache = nil + begin + @@litter_semaphore.synchronize { + littercache = @@litters.dup + } + rescue ThreadError => e + # already locked by a parent caller and this is a read op, so this is ok + raise e if !e.message.match(/recursive locking/) + littercache = @@litters.dup + end + littercache.each_pair { |deploy, momma| + @@deploy_struct_semaphore.synchronize { + @deploy_cache[deploy] = { + "mtime" => Time.now, + "data" => momma.deployment + } + } + } deploy_root = File.expand_path(MU.dataDir+"/deployments") MU::MommaCat.deploy_struct_semaphore.synchronize { - if Dir.exists?(deploy_root) + if Dir.exist?(deploy_root) Dir.entries(deploy_root).each { |deploy| this_deploy_dir = deploy_root+"/"+deploy - next if deploy == "." or deploy == ".." or !Dir.exists?(this_deploy_dir) + next if deploy == "." or deploy == ".." or !Dir.exist?(this_deploy_dir) next if deploy_id and deploy_id != deploy if !File.size?(this_deploy_dir+"/deployment.json") @@ -2590,7 +3028,7 @@ def self.getResourceMetadata(type, name: nil, deploy_id: nil, use_cache: true, m # Populate some generable entries that should be in the deploy # data. Also, bounce out if we realize we've found exactly what # we needed already. - MU::Cloud.resource_types.each_pair { |res_type, attrs| + MU::Cloud.resource_types.values.each { |attrs| next if @deploy_cache[deploy]['data'][attrs[:cfg_plural]].nil? if !attrs[:has_multiples] @@ -2635,10 +3073,10 @@ def self.getResourceMetadata(type, name: nil, deploy_id: nil, use_cache: true, m next if !@deploy_cache[deploy]['data'].has_key?(type) if !name.nil? next if @deploy_cache[deploy]['data'][type][name].nil? - matches[deploy] = [] if !matches.has_key?(deploy) + matches[deploy] ||= [] matches[deploy] << @deploy_cache[deploy]['data'][type][name].dup else - matches[deploy] = [] if !matches.has_key?(deploy) + matches[deploy] ||= [] matches[deploy].concat(@deploy_cache[deploy]['data'][type].values) end } @@ -2648,7 +3086,7 @@ def self.getResourceMetadata(type, name: nil, deploy_id: nil, use_cache: true, m !@deploy_cache[deploy_id]['data'][type].nil? if !name.nil? if !@deploy_cache[deploy_id]['data'][type][name].nil? - matches[deploy_id] = [] if !matches.has_key?(deploy_id) + matches[deploy_id] ||= [] matches[deploy_id] << @deploy_cache[deploy_id]['data'][type][name].dup else return matches # nothing, actually @@ -2679,13 +3117,14 @@ def loadDeploy(deployment_json_only = false, set_context_to_me: true) begin @deployment = JSON.parse(File.read("#{deploy_dir}/deployment.json")) rescue JSON::ParserError => e - MU.log "JSON parse failed on #{deploy_dir}/deployment.json", MU::ERR + MU.log "JSON parse failed on #{deploy_dir}/deployment.json", MU::ERR, details: e.message end deploy.flock(File::LOCK_UN) deploy.close if set_context_to_me ["appname", "environment", "timestamp", "seed", "handle"].each { |var| + @deployment[var] ||= instance_variable_get("@#{var}".to_sym) if @deployment[var] if var != "handle" MU.setVar(var, @deployment[var].upcase) @@ -2712,7 +3151,7 @@ def loadDeploy(deployment_json_only = false, set_context_to_me: true) begin @original_config = JSON.parse(File.read("#{deploy_dir}/basket_of_kittens.json")) rescue JSON::ParserError => e - MU.log "JSON parse failed on #{deploy_dir}/basket_of_kittens.json", MU::ERR + MU.log "JSON parse failed on #{deploy_dir}/basket_of_kittens.json", MU::ERR, details: e.message end end if File.exist?(deploy_dir+"/ssh_key_name") @@ -2733,7 +3172,7 @@ def loadDeploy(deployment_json_only = false, set_context_to_me: true) if Dir.exist?("#{deploy_dir}/secrets") @secrets.each_key { |type| Dir.glob("#{deploy_dir}/secrets/#{type}.*") { |filename| - base, server = File.basename(filename).split(/\./) + server = File.basename(filename).split(/\./)[1] @secrets[type][server] = File.read(filename).chomp! } @@ -2742,9 +3181,10 @@ def loadDeploy(deployment_json_only = false, set_context_to_me: true) } end - @catadjs = %w{fuzzy ginger lilac chocolate xanthic wiggly itty} - @catnouns = %w{bastet biscuits bobcat catnip cheetah chonk dot felix jaguar kitty leopard lion lynx maru mittens moggy neko nip ocelot panther patches paws phoebe purr queen roar saber sekhmet skogkatt socks sphinx spot tail tiger tom whiskers wildcat yowl floof beans ailurophile dander dewclaw grimalkin kibble quick tuft misty simba mew quat eek ziggy} - @catmixed = %w{abyssinian angora bengal birman bobtail bombay burmese calico chartreux cheshire cornish-rex curl devon egyptian-mau feline furever fumbs havana himilayan japanese-bobtail javanese khao-manee maine-coon manx marmalade mau munchkin norwegian pallas persian peterbald polydactyl ragdoll russian-blue savannah scottish-fold serengeti shorthair siamese siberian singapura snowshoe stray tabby tonkinese tortoiseshell turkish-van tuxedo uncia caterwaul lilac-point chocolate-point mackerel maltese knead whitenose vorpal} + # 2019-06-03 adding things from https://aiweirdness.com/post/185339301987/once-again-a-neural-net-tries-to-name-cats + @catadjs = %w{fuzzy ginger lilac chocolate xanthic wiggly itty chonky norty slonky floofy} + @catnouns = %w{bastet biscuits bobcat catnip cheetah chonk dot felix hamb jaguar kitty leopard lion lynx maru mittens moggy neko nip ocelot panther patches paws phoebe purr queen roar saber sekhmet skogkatt socks sphinx spot tail tiger tom whiskers wildcat yowl floof beans ailurophile dander dewclaw grimalkin kibble quick tuft misty simba slonk mew quat eek ziggy whiskeridoo cromch monch screm} + @catmixed = %w{abyssinian angora bengal birman bobtail bombay burmese calico chartreux cheshire cornish-rex curl devon egyptian-mau feline furever fumbs havana himilayan japanese-bobtail javanese khao-manee maine-coon manx marmalade mau munchkin norwegian pallas persian peterbald polydactyl ragdoll russian-blue savannah scottish-fold serengeti shorthair siamese siberian singapura snowshoe stray tabby tonkinese tortoiseshell turkish-van tuxedo uncia caterwaul lilac-point chocolate-point mackerel maltese knead whitenose vorpal chewie-bean chicken-whiskey fish-especially thelonious-monsieur tom-glitter serendipitous-kill sparky-buttons} @catwords = @catadjs + @catnouns + @catmixed @jaegeradjs = %w{azure fearless lucky olive vivid electric grey yarely violet ivory jade cinnamon crimson tacit umber mammoth ultra iron zodiac} @@ -2756,4 +3196,3 @@ def loadDeploy(deployment_json_only = false, set_context_to_me: true) end #class end #module - diff --git a/modules/tests/super_complex_bok.yml b/modules/tests/super_complex_bok.yml old mode 100755 new mode 100644 diff --git a/modules/tests/super_simple_bok.yml b/modules/tests/super_simple_bok.yml old mode 100755 new mode 100644 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..ff9eed216 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +# Modules we would like installed to our bundled Python (the RPM "muthon"). +ansible>=2.7 +awscli +gcloud +azure-cli diff --git a/roles/mu-master-jenkins.json b/roles/mu-master-jenkins.json deleted file mode 100644 index c43edd08f..000000000 --- a/roles/mu-master-jenkins.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "name": "mu-master-jenkins", - "description": "Role for a Jenkins instance running on a mu master (only)", - "json_class": "Chef::Role", - "default_attributes": { - "jenkins_port_external": 9443, - "jenkins_port_internal": 8080 - }, - "override_attributes": { - "jenkins": { - "master": { - "home": "/home/jenkins", - "jvm_options":"-Djenkins.install.runSetupWizard=false -Xbootclasspath/p:/home/jenkins/alpn-boot-8.1.11.v20170118.jar" - } - } - }, - "chef_type": "role", - "run_list": [ - "recipe[java]", - "recipe[jenkins::master]", - "recipe[mu-jenkins]" - ], - "env_run_lists": {} -} diff --git a/roles/mu-master.json b/roles/mu-master.json index e8b2e21a8..274b7e7cb 100644 --- a/roles/mu-master.json +++ b/roles/mu-master.json @@ -5,7 +5,8 @@ "run_list": [ "recipe[mu-tools::base_repositories]", "recipe[mu-tools::nrpe]", - "recipe[mu-master]" + "recipe[mu-master]", + "recipe[mu-master::kubectl]" ], "description": "Run List for Mu master servers", "chef_type": "role" diff --git a/spec/azure_creds b/spec/azure_creds new file mode 100644 index 000000000..4accc1355 --- /dev/null +++ b/spec/azure_creds @@ -0,0 +1,5 @@ +//SET THIS VALUE FOR LOCAL TESTING +{ +"client_id": "__AZURE_CLIENT_ID__", +"client_secret": "__AZURE_CLIENT_SECRET__" +} \ No newline at end of file diff --git a/spec/mu.yaml b/spec/mu.yaml new file mode 100644 index 000000000..a3a573176 --- /dev/null +++ b/spec/mu.yaml @@ -0,0 +1,56 @@ +--- +installdir: "/opt/mu" +libdir: "/opt/mu/lib" +hostname: mu-master +ssl: + cert: "/opt/mu/var/ssl/mommacat.crt" + key: "/opt/mu/var/ssl/mommacat.key" + chain: "/opt/mu/var/ssl/Mu_CA.pem" +mu_admin_email: example@example.com +allow_invade_foreign_vpcs: false +mu_repo: cloudamatic/mu.git +public_address: 10.0.0.1 +banner: Mu Master +scratchpad: + template_path: "/opt/mu/lib/modules/scratchpad.erb" + max_age: 3600 +ldap: + type: 389 Directory Services + base_dn: OU=Mu,DC=platform-mu + user_ou: OU=Users,OU=Mu,DC=platform-mu + group_ou: OU=Groups,OU=Mu,DC=platform-mu + bind_creds: + vault: mu_ldap + item: mu_bind_acct + username_field: username + password_field: password + join_creds: + vault: mu_ldap + item: mu_join_acct + username_field: username + password_field: password + domain_name: platform-mu + domain_netbios_name: mu + user_group_dn: CN=mu-users,OU=Groups,OU=Mu,DC=platform-mu + user_group_name: mu-users + admin_group_dn: CN=mu-admins,OU=Groups,OU=Mu,DC=platform-mu + admin_group_name: mu-admins + dcs: + - 127.0.0.1 +mu_admin_name: Mu Administrator +mu_repository: git://github.com/cloudamatic/mu.git +repos: +- https://github.com/cloudamatic/mu_demo_platform +azure: + Azure: + directory_id: AZURE_DIRECTORY_ID + subscription: AZURE_SUBSCIPTION_ID + credentials_file: "spec/azure_creds" + default: true + default_region: eastus + name: Azure +multiuser: true +config_files: +- "/opt/mu/etc/mu.yaml" +datadir: "/opt/mu/var" +master_runlist_extras: \ No newline at end of file diff --git a/spec/mu/clouds/azure_spec.rb b/spec/mu/clouds/azure_spec.rb index 5766b6ff7..ae78859bb 100644 --- a/spec/mu/clouds/azure_spec.rb +++ b/spec/mu/clouds/azure_spec.rb @@ -1,26 +1,33 @@ require 'spec_helper' +require 'yaml' require 'mu/clouds/azure' + describe MU::Cloud::Azure do + is_azure_for_rizzle = MU::Cloud::Azure.hosted? - # @azure = nil + before(:all) do + if File.file?("/opt/mu/etc/mu.yaml") + $MU_CFG = YAML.load(File.read("/opt/mu/etc/mu.yaml")) + else + $MU_CFG = YAML.load(File.read("spec/mu.yaml")) + end + end - # before(:all) do - # @azure = MU::Cloud::Azure.new - # end + p "It is #{is_azure_for_rizzle} that I am hosted in Azure I will test accordingly" describe ".hosted?" do - it "responds with true or false" do - expect(MU::Cloud::Azure.hosted?).to be(true).or be(false) + it "responds with #{is_azure_for_rizzle}" do + expect(MU::Cloud::Azure.hosted?).to be(is_azure_for_rizzle) end end describe ".hosted" do - it "responds with true or false" do - expect(MU::Cloud::Azure.hosted?).to be(true).or be(false) + it "responds with #{is_azure_for_rizzle}" do + expect(MU::Cloud::Azure.hosted?).to be(is_azure_for_rizzle) end end @@ -32,51 +39,181 @@ end end - describe ".listRegions" do - it "responds with false" do - expect(MU::Cloud::Azure.listRegions).to eql("TODO") + describe ".default_subscription" do + it "returns a subscription string" do + expect(MU::Cloud::Azure.default_subscription()).to be_a(String) end end - describe ".listAZs" do - it "responds with false" do - expect(MU::Cloud::Azure.listAZs).to eql("TODO") + describe ".listRegions" do + before(:all) do + @regionList = MU::Cloud::Azure.listRegions() + end + + it "responds with an array" do + expect(@regionList.class).to eql(Array) + expect(MU::Cloud::Azure.listRegions().class).to eql(Array) + end + + it "responds with an array of strings" do + expect(@regionList).to all( be_a(String) ) + expect(MU::Cloud::Azure.listRegions()).to all( be_a(String) ) end end describe ".hosted_config" do - it "responds with false" do - expect(MU::Cloud::Azure.hosted_config).to eql("TODO") + if is_azure_for_rizzle + it "responds with a valid configuation hash" do + example = MU::Cloud::Azure.hosted_config() + #TODO DETERMINE WHAT ARE REQUIRED CONFIGURATIONS + #expect(example).to have_key('credentials_file') + #expect(example).to have_key('log_bucket_name') + expect(example).to have_key('region') + expect(example).to have_key('subscriptionId') + end + else + it "responds with nil" do + expect(MU::Cloud::Azure.hosted_config).to eql(nil) + end end end describe ".config_example" do - it "responds with false" do - expect(MU::Cloud::Azure.config_example).to eql("TODO") + if is_azure_for_rizzle + it "responds with a valid configuation hash" do + example = MU::Cloud::Azure.config_example() + expect(example).to have_key('credentials_file') + expect(example).to have_key('log_bucket_name') + expect(example).to have_key('region') + expect(example).to have_key('subscriptionId') + end + it "responds with the correct region" do + example = MU::Cloud::Azure.config_example() + expect(example['region']).to eql(MU::Cloud::Azure.myRegion()) + end + else + default_sample = {"credentials_file"=>"~/.azure/credentials", "log_bucket_name"=>"my-mu-s3-bucket", "region"=>"eastus", "subscriptionId"=>"99999999-9999-9999-9999-999999999999"} + + it "example matches sample" do + expect(MU::Cloud::Azure.config_example).to eql(default_sample) + end end end describe ".writeDeploySecret" do - it "responds with false" do + it "responds with TODO" do expect(MU::Cloud::Azure.writeDeploySecret).to eql("TODO") end end describe ".listCredentials" do - it "responds with false" do + it "responds with TODO" do expect(MU::Cloud::Azure.listCredentials).to eql("TODO") end end - describe ".credConfig" do - it "responds with false" do - expect(MU::Cloud::Azure.credConfig).to eql("TODO") + # describe ".credConfig" do + # if is_azure_for_rizzle + # it "responds with TODO" do + # expect(MU::Cloud::Azure.credConfig).to eql({"TODO":"TODO"}) + # end + # else + # it "returns nil because no credentials are configured" do + # expect(MU::Cloud::Azure.credConfig).to be_nil + # end + # end + # end + + # describe ".listInstanceTypes" do + # it "responds with TODO" do + # expect(MU::Cloud::Azure.listInstanceTypes).to eql("TODO") + # end + # end + + describe ".get_metadata" do + if is_azure_for_rizzle + it "responds with a hash of expected metadata" do + metadata = MU::Cloud::Azure.get_metadata() + expect(metadata).to have_key('compute') + expect(metadata).to have_key('network') + expect(metadata['compute']).to have_key('location') + expect(metadata['compute']).to have_key('name') + expect(metadata['compute']).to have_key('osType') + expect(metadata['compute']).to have_key('subscriptionId') + expect(metadata['compute']).to have_key('vmId') + end + else + it "responds with nil if not hosted in azure" do + expect(MU::Cloud::Azure.get_metadata).to be_nil + end end end - - describe ".listInstanceTypes" do - it "responds with false" do - expect(MU::Cloud::Azure.listInstanceTypes).to eql("TODO") + + describe ".list_subscriptions" do + before(:all) do + @subscriptions = MU::Cloud::Azure.list_subscriptions() + end + + it "responds with an array" do + expect(@subscriptions.class).to eql(Array) + end + + it "responds with an array of strings" do + expect(@subscriptions).to all( be_a(String) ) end end + + describe ".listAZs" do + before(:all) do + @azs = MU::Cloud::Azure.listAZs('eastus') + end + + it "responds with an array" do + expect(@azs.class).to eql(Array) + end + + it "responds with an array of strings" do + expect(@azs).to all( be_a(String) ) + end + + it "responds with valid array of AZs if region is passed" do + expect(MU::Cloud::Azure.listAZs('eastus')).to eql(['1', '2', '3']) + end + + it "responds with empty array of AZs if invalid region is passed" do + expect(MU::Cloud::Azure.listAZs('westus')).to eql([]) + end + + it "responds with empty array of AZs if no region is passed" do + expect(MU::Cloud::Azure.listAZs()).to eql([]) + end + end + + describe ".myRegion" do + before(:each) do + MU::Cloud::Azure.class_variable_set :@@myRegion_var, nil + end + + after(:each) do + $MU_CFG = YAML.load(File.read("spec/mu.yaml")) + end + + if !is_azure_for_rizzle + it "responds with nil" do + $MU_CFG['azure']['Azure'].delete('default_region') + + expect( MU::Cloud::Azure.myRegion()).to eql(nil) + end + end + + it "responds with a string" do + expect( MU::Cloud::Azure.myRegion().class).to eql(String) + + $MU_CFG['azure']['Azure'].delete('default_region') + + expect( MU::Cloud::Azure.myRegion().class).to eql(String) + end + + end + end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e51a5a7cb..bcef6f313 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -14,6 +14,11 @@ # # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +require 'simplecov' +require 'simplecov-console' +SimpleCov.formatter = SimpleCov::Formatter::Console +SimpleCov.start + $LOAD_PATH << "#{File.realpath(File.expand_path(File.dirname(__FILE__))+"/..")}/modules" # require 'mu' diff --git a/test/clean_up.py b/test/clean_up.py old mode 100755 new mode 100644 diff --git a/test/exec_inspec.py b/test/exec_inspec.py old mode 100755 new mode 100644 diff --git a/test/exec_mu_install.py b/test/exec_mu_install.py old mode 100755 new mode 100644 diff --git a/test/exec_retry.py b/test/exec_retry.py old mode 100755 new mode 100644 diff --git a/test/smoke_test.rb b/test/smoke_test.rb old mode 100755 new mode 100644