Infrastructure as CodeとYAML定義変数

Ansible等でInfrastructure as Codeすると、自分が毎回使うインフラ構成パターンを記載したロジック部分と、そのロジックに注入するプロジェクトごとの変数にファイルを分けることになる。変数がYAMLで書かれているとして、そのYAMLで定義した変数をもとに、サーバ情報を表示するWEBページを生成する。

題材にはJinja2 + YAMLでTerraformをより簡潔に記載するで使ったYAMLを使用したい。

all.yml

project: myproject
ami: ami-XXXXXXXXXXXXXXXXX
access_key: "XXXXXXXXXXXXXXXX"
secret_key: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
region: ap-northeast-1
vpc:
  name: vpc_1
  cidr_block: 10.1.0.0/16
  open_subnets:
    ap-northeast-1a: 10.1.0.0/19
    ap-northeast-1c: 10.1.64.0/19
    ap-northeast-1d: 10.1.128.0/19
  close_subnets:
    ap-northeast-1a: 10.1.32.0/19
    ap-northeast-1c: 10.1.96.0/19
    ap-northeast-1d: 10.1.160.0/19

our_cidr_blocks:
  - xxx.xxx.xxx.xxx/32 # MY OFFICE
security:
  - name: bastion
    port: 22
    protocol: tcp
    cidr_blocks:
      - yyy.yyy.yyy.yyy/32 # additional cidr for bastion

default_security:
#    cidr_blocks:
#      - zzz.zzz.zzz.zzz/32 # bastion

resource.yml

ec2:
  - instances:
      - bastion0001
    type: t2.micro
    subnets:
      type: open
      use:
        - ap-northeast-1a
    security_groups:
      - bastion
    ebs:
      - type: gp2
        size: 100
        device_name: /dev/sdb
    eip: true

静的HTML

HTMLは毎回生成するのではなく、静的ファイルとする。サーバ構成はプロジェクトごとに異なるだろうが、動的な部分は全てJavaScriptに追い出す。

今回はVue.jsを使っている。

index.html

<html>
  <head>
    <link rel="stylesheet" href="index.css">
    <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
  </head>

  <!-- component template -->
  <script type="text/x-template" id="grid-template">
    <table>
      <thead>
        <tr>
          <th v-for="key in columns"
            @click="sortBy(key)"
            :class="{ active: sortKey == key }">
            {{ key }}
            <span class="arrow" :class="sortOrders[key] > 0 ? 'asc' : 'dsc'">
            </span>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="entry in filteredData">
          <td v-for="key in columns" v-html="entry[key]">
          </td>
        </tr>
      </tbody>
    </table>
  </script>

  <body>
    <h1 id="project">[{{ project }}] AWSサーバ情報</h1>
    <div id="ymls">
      <div v-for="region in regions">
        <h2>[[ {{ region.name }} ]]</h2>
        <div v-for="e in region.env">
        <h3>{{ e.name }}</h3>
          <div name="resource" v-for="r in e.resource">
            <div v-if="r.gridData.length > 0">
              <h3>{{ r.name }}</h3>
              <form id="search">
                Search <input name="query" v-model="r.searchQuery">
              </form>
              <grid
                :data="r.gridData"
                :columns="r.gridColumns"
                :filter-key="r.searchQuery">
              </grid>
            </div>
          </div>
        </div>
      </div>
    </div>

    <script src="index.js"></script>
  </body>
</html>

ついでにCSSも掲載する。

index.css

body {
  font-family: Helvetica Neue, Arial, sans-serif;
  font-size: 14px;
  color: #444;
}

table {
  border: 2px solid #42b983;
  border-radius: 3px;
  background-color: #fff;
}

th {
  background-color: #42b983;
  color: rgba(255,255,255,0.66);
  cursor: pointer;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}

td {
  background-color: #f9f9f9;
}

th, td {
  min-width: 120px;
  padding: 10px 20px;
}

th.active {
  color: #fff;
}

th.active .arrow {
  opacity: 1;
}

.arrow {
  display: inline-block;
  vertical-align: middle;
  width: 0;
  height: 0;
  margin-left: 5px;
  opacity: 0.66;
}

.arrow.asc {
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-bottom: 4px solid #fff;
}

.arrow.dsc {
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 4px solid #fff;
}

div[name="resource"] {
  margin-left: 25px;
}

YAMLをPythonで変換し、JavaScriptのオブジェクトとして出力する

JavaScriptの大枠はJinja2としてテンプレート定義する。

index.js

Vue.component('grid', {
  template: '#grid-template',
  props: {
    data: Array,
    columns: Array,
    filterKey: String
  },
  data: function () {
    var sortOrders = {}
    this.columns.forEach(function (key) {
      sortOrders[key] = 1
    })
    return {
      sortKey: '',
      sortOrders: sortOrders
    }
  },
  computed: {
    filteredData: function () {
      var sortKey = this.sortKey
      var filterKey = this.filterKey && this.filterKey.toLowerCase()
      var order = this.sortOrders[sortKey] || 1
      var data = this.data
      if (filterKey) {
        data = data.filter(function (row) {
          return Object.keys(row).some(function (key) {
            return String(row[key]).toLowerCase().indexOf(filterKey) > -1
          })
        })
      }
      if (sortKey) {
        data = data.slice().sort(function (a, b) {
          a = a[sortKey]
          b = b[sortKey]
          return (a === b ? 0 : a > b ? 1 : -1) * order
        })
      }
      return data
    }
  },
  filters: {
    capitalize: function (str) {
      return str.charAt(0).toUpperCase() + str.slice(1)
    }
  },
  methods: {
    sortBy: function (key) {
      this.sortKey = key
      this.sortOrders[key] = this.sortOrders[key] * -1
    }
  }
})

document.title = '{{ project }}'.toUpperCase()

var project = new Vue({
  el: '#project',
  data: {
    project: '{{ project }}'
  }
})

var ymls = new Vue({
  el: '#ymls',
  data: {
    regions: [
      {%- for region in regions %}
      { name: '{{ region.name }}',
        env: [
          {%- for yml in region.ymls %}
          { name: '{{ yml.name }}',
            resource: [
              {%- for i in yml.info %}
              { name: '{{ i.name }}',
                searchQuery: '',
                gridColumns: {{ i.header }},
                gridData: {{ i.data }}
              },
              {%- endfor %}
            ]
          },
          {%- endfor %}
        ]
      },
      {%- endfor %}
    ]
  }
})

new Vue()部分でJinja2によりサーバ情報を詰め込むように、変数やfor文を入れている。

YAMLを読み込んでサーバ情報に変換するプログラムは以下のようなものになる。

create_index_js.py

#!/usr/bin/env python

import os
import sys
import yaml
import glob
from jinja2 import Environment, FileSystemLoader

def create_template_vars_info(name, header, vals):
  return {
            'name': name,
            'header': header,
            'data': [ {h:str(v) for v, h in zip(val, header)} for val in vals ]
         }

template_vars = {
  'project': '',
  'regions': [
#    { 'name': '',
#      'ymls': [
#        { 'name': '',
#          'info': [
#            { 'name': '',
#              'header': [],
#              'data': []
#            }
#           ]
#        }
#      ]
#    }
  ]
}

except_dirs = ['sample', 'template', 'venv', os.path.basename(__file__)]
regions = [d for d in map(os.path.basename, glob.glob('./*')) if d not in except_dirs]

for region in regions:
  template_vars['regions'].append({'name': region, 'ymls': []})

  all_yml = f'./{region}/all.yml'
  with open(all_yml, "r") as f:
    all_yml_vars = yaml.load(f)
    template_vars['project'] = all_yml_vars['project']

  ymls = glob.glob(f'./{region}/*/resource.yml')
  for yml in ymls:
    with open(yml, "r") as f:
      variables = yaml.load(f)

    dirname = os.path.basename(os.path.dirname(yml))

    info = []

    ## open EC2
    header = ['instance', 'type', 'EBS', 'security_group', 'EIP']
    vals = []
    for e in filter(lambda ec2: ec2['subnets']['type'] == 'open', variables['ec2']):
      for i in e['instances']:
        ebs = map(lambda e: e['type'] + ':' + str(e['size']), e.get('ebs', []))
        vals.append([i, e['type'], '<br>'.join(ebs), ','.join(e['security_groups']), e.get('eip', False)])
    info.append(create_template_vars_info('EC2 on public subnet', header, vals))

    ## close EC2
    header = ['instance', 'type', 'EBS']
    vals = []
    for e in filter(lambda ec2: ec2['subnets']['type'] == 'close', variables['ec2']):
      for i in e['instances']:
        ebs = map(lambda e: e['type'] + ':' + str(e['size']), e.get('ebs', []))
        vals.append([i, e['type'], '<br>'.join(ebs)])
    info.append(create_template_vars_info('EC2 on close subnet', header, vals))

    template_vars['regions'][-1]['ymls'].append({'name': dirname, 'info': info})


env = Environment(loader=FileSystemLoader('./sample'))
print(env.get_template('index.js').render(template_vars))

Terraform + Ansible + Python/Jinja2 + HTML/JSでインフラ全般の構成情報WEBサイトを作る

今回はTerraformで定義した変数をもとにWEBページを生成したが、Ansibleの変数定義を書いたYAMLもあわせて読み込んでJavaScriptのオブジェクトに変換することで、ネットワーク構成情報、サーバ構成情報だけでなく、サーバ内のミドルウェア設定等もドキュメントに起こすことができる。

さらに、構築したあとの構成情報として使えるのはもちろんのこと、今から構築しようとしているサーバについてYAMLの記載ミスがないかどうか、WEBページという視覚的によりわかりやすい形で見ることができる。