【GCP】Booting from Hard Disk 0… から復旧させた話【P2V】

こんにちは。
先日に投稿した下記方法で GCP に P2V (RHEL6) を試みました。
…が、上手くVM が起動しなかったため、頑張って復旧させた際の備忘録です。

【V2V】dd コマンドでデータを丸ごと吸い出して GCP にサーバを移設してみる


シリアルコンソールから確認すると、出力が Booting from Hard Disk 0… にて止まっている模様。
仕方がないので、レスキュー用の VM を起動し、正常にブートするディスク (復旧ディスク) に対して、問題の (起動しない) ディスクデータを移行し VM の起動を目指します。

1. 復旧ディスクを用意

GCP では既に RHEL6 / CentOS6 のイメージを提供していません。
ついては、AWS にて CentOS6 のイメージを起動し、dd コマンドでディスクのイメージを取得してから GCP にて VM を作成します。
※ 詳細については こちら を参照ください。

※ 必要に応じてディスクを拡張しておきます。

sed -i -e "s/^mirrorlist=http:\/\/mirrorlist.centos.org/#mirrorlist=http:\/\/mirrorlist.centos.org/g" /etc/yum.repos.d/CentOS-Base.repo
sed -i -e "s/^#baseurl=http:\/\/mirror.centos.org/baseurl=http:\/\/vault.centos.org/g" /etc/yum.repos.d/CentOS-Base.repo
yum install epel-release
yum install cloud-utils-growpart
growpart /dev/sda 1
reboot
resize2fs /dev/sda1

2. データ移行

レスキュー用の VM を起動し、「1」にて作成したディスクと、問題の (起動しない) ディスクをアタッチし、それぞれ適当なディレクトリにマウントします。

※ /mnt/source01 → 問題の (起動しない) ディスク
※ /mnt/dest01 → 復旧ディスク

データ移行には rsync を使います。
※ 予め同期させないデータのリストファイルを作成してから実行します。

vim /var/tmp/exclude_list.txt 
=============================
# necessary-file exclude
- /boot/*
- /dev/*
- /sys/*
- /proc/*
- /etc/fstab
- /etc/passwd
- /etc/shadow
- /run/*

nohup rsync -av --exclude-from=/var/tmp/exclude_list.txt /mnt/source01/* /mnt/dest01/ &

※ OS ユーザはエントリの重複が発生しないように /etc/passwd 、/etc/shadow に手動で追記します。

データ同期が完了したら、ディスクをアンマウントし VM を起動します。

3. ネットワーク周り調整

VM 起動後はシリアルコンソールから接続します。
※ ログインプロンプトが表示されたら成功です。
P2V だとネットワーク周りの設定が競合して疎通ができない状態になっていると思うので適宜調整します。

※ たいていは network-scripts 配下のファイルを調整し、75-persistent-net-generator.rules を退避(削除)すれば良いかと。

調整後は VM を再起動して下さい。

vim /etc/sysconfig/network-scripts/ifcfg-eth0
=============================================
IPV6INIT="no"
DHCP_HOSTNAME="localhost"
BOOTPROTO="dhcp"
DEVICE="eth0"
ONBOOT="yes"
MTU=1460
PERSISTENT_DHCLIENT="y"
IPV6INIT=yes

mv /etc/udev/rules.d/75-persistent-net-generator.rules /var/tmp/

※ GATEWAY は VPC のゲートウェイですね。
vim /etc/sysconfig/network
==========================
NETWORKING=yes
HOSTNAME=hogehoge
NETWORKING_IPV6=no
NOZEROCONF=YES
GATEWAY=hogehoge

4. 終わりに

VM が起動しない時は手詰まりで泣きそうになりました…。
一か八かでデータの同期を試みたところ正常に起動したようで何よりです。
この案件、色々と勉強になりました…。

【V2V】dd コマンドでデータを丸ごと吸い出して GCP にサーバを移設してみる

こんにちは。
dd コマンドでディスクのデータを丸ごと吸い出して、GCP にサーバ移設を行う方法について検証してみたので備忘録を残しておきます。

前提として、AWS に構築した WEB / DB の仮想サーバ( EC2 )を、GCP に移設してみます。

1. 移設元

事前にデータを吸い出すディスクよりも大きなボリュームを EC2 にアタッチ / マウントしておきます。
あとは dd コマンドで吸い出すだけで OK です。
吸い出したデータは tar.gz で固めておきます。
※ デバイスファイル名は適宜変更してください。

fdisk /dev/xvdf
mkfs -t xfs /dev/xvdf1
mkdir -p /tmp/mnt
mount -t xfs /dev/xvdf1 /tmp/mnt
dd if=/dev/xvda of=/tmp/mnt/disk.raw bs=4M conv=sparse
cd /tmp/mnt
tar --format=oldgnu -Sczf /tmp/mnt/sanuki-source-wd01-compressed-image.tar.gz disk.raw

固めたデータは GCS ( Cloud Storage )にアップロードします。
方法はなんでも大丈夫ですが、gsutil を使うのが一番手っ取り早いかと考えます。
※ バケット名は適宜変更してください。

cd ~
curl https://sdk.cloud.google.com > install.sh
bash install.sh --disable-prompts
./google-cloud-sdk/bin/gcloud init
./google-cloud-sdk/bin/gsutil mb gs://sanuki-v2v-test-bucket01
./google-cloud-sdk/bin/gsutil ls
./google-cloud-sdk/bin/gsutil cp /tmp/mnt/sanuki-source-wd01-compressed-image.tar.gz gs://sanuki-v2v-test-bucket01/

2. 移設先

・Cloud Storage にアップロードしたファイルを元にイメージを作成しておきます。

イメージ > イメージを作成 > ソース : Cloud Storage ファイル > 作成

・作成したイメージから VM を起動します。
※ VM は sanuki-dest-wd01 という名称にしておきます。

VM インスタンス > インスタンスの作成 > ブートディスク : カスタムイメージ から 作成したイメージを選択 > 作成

…が、VM は正常に起動しません。

シリアルコンソールから VM に接続するとレスキューモードになっており、ログを漁ったところ、どうも File System が壊れているようです。

Feb 15 08:41:23 localhost kernel: XFS (sda1): Unmount and run xfs_repair
Feb 15 08:41:23 localhost kernel: XFS (sda1): First 64 bytes of corrupted metada
Feb 15 08:41:23 localhost kernel: ffff96cdf565fe00: 58 41 47 46 00 00 00 01 00 0
Feb 15 08:41:23 localhost kernel: ffff96cdf565fe10: 00 00 00 01 00 00 00 02 00 0
Feb 15 08:41:23 localhost kernel: ffff96cdf565fe20: 00 00 00 01 00 00 00 00 00 0
Feb 15 08:41:23 localhost kernel: ffff96cdf565fe30: 00 00 00 04 00 07 bf 6b 00 0
Feb 15 08:41:23 localhost kernel: XFS (sda1): metadata I/O error: block 0x1bff20
Feb 15 08:41:23 localhost mount[343]: mount: mount /dev/sda1 on /sysroot failed:
Feb 15 08:41:23 localhost systemd[1]: sysroot.mount mount process exited, code=e
Feb 15 08:41:23 localhost systemd[1]: Failed to mount /sysroot.
Feb 15 08:41:23 localhost systemd[1]: Dependency failed for Initrd Root File Sys
Feb 15 08:41:23 localhost systemd[1]: Dependency failed for Reload Configuration
Feb 15 08:41:23 localhost systemd[1]: Job initrd-parse-etc.service/start failed 
Feb 15 08:41:23 localhost systemd[1]: Triggering OnFailure= dependencies of init
Feb 15 08:41:23 localhost systemd[1]: Job initrd-root-fs.target/start failed wit
Feb 15 08:41:23 localhost systemd[1]: Triggering OnFailure= dependencies of init
Feb 15 08:41:23 localhost systemd[1]: Unit sysroot.mount entered failed state

3. 復旧

復旧用 VM を用意して、壊れたブートディスクをセカンダリとしてアタッチしリペアを試みます。

・ディスクだけ残し VM を削除します。

VM インスタンス > sanuki-dest-wd01 > 編集 > ブートディスク : 削除ルール : ディスクを維持 > 保存 > 削除

・リカバリ用の VM を作成して、壊れたディスクをアタッチします。
※ VM は sanuki-recover01 という名称にしておきます。

VM インスタンス > インスタンスの作成 > ブートディスク : 公開イメージ : CentOS7 > 作成

VM インスタンス > sanuki-recover01 > 編集 > 追加ディスク : 既存のディスクを接続 > sanuki-dest-wd01 : 保存 > 保存

・リカバリ用の VM に接続し、File System のリペアを試みます。
先に復旧対象のデバイスファイルを確認しておきます。
今回は /dev/sdb1 が対象になります。

[root@sanuki-recover01 ~]# lsblk
NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
sda      8:0    0   20G  0 disk
tqsda1   8:1    0  200M  0 part /boot/efi
mqsda2   8:2    0 19.8G  0 part /
sdb      8:16   0   30G  0 disk
mqsdb1   8:17   0   30G  0 part
[root@sanuki-recover01 ~]# ls -l /dev/disk/by-id/ | grep "sanuki-dest-wd01"
lrwxrwxrwx. 1 root root  9 Feb 15 11:40 google-sanuki-dest-wd01 -> ../../sdb
lrwxrwxrwx. 1 root root 10 Feb 15 11:40 google-sanuki-dest-wd01-part1 -> ../../sdb1
lrwxrwxrwx. 1 root root  9 Feb 15 11:40 scsi-0Google_PersistentDisk_sanuki-dest-wd01 -> ../../sdb
lrwxrwxrwx. 1 root root 10 Feb 15 11:40 scsi-0Google_PersistentDisk_sanuki-dest-wd01-part1 -> ../../sdb1

いきなり xfs_repair を行うと怒られました。
一度 マウント / アンマウント する必要があるようです。

[root@sanuki-recover01 ~]# xfs_repair /dev/sdb1
Phase 1 - find and verify superblock...
        - reporting progress in intervals of 15 minutes
Phase 2 - using internal log
        - zero log...
ERROR: The filesystem has valuable metadata changes in a log which needs to
be replayed.  Mount the filesystem to replay the log, and unmount it before
re-running xfs_repair.  If you are unable to mount the filesystem, then use
the -L option to destroy the log and attempt a repair.
Note that destroying the log may cause corruption -- please attempt a mount
of the filesystem before doing this.

適当にディレクトリを切ってから マウント → アンマウント → xfs_repair を行います。

mkdir /tmp/mnt
mount /dev/sdb1 /tmp/mnt
umount /dev/sdb1
xfs_repair /dev/sdb1

…正常にリペアできました。

[root@sanuki-recover01 ~]# xfs_repair /dev/sdb1
Phase 1 - find and verify superblock...
        - reporting progress in intervals of 15 minutes
Phase 2 - using internal log
        - zero log...
        - scan filesystem freespace and inode maps...
agi unlinked bucket 29 is 23389 in ag 13 (inode=54549341)
        - 11:45:46: scanning filesystem freespace - 16 of 16 allocation groups done
        - found root inode chunk
Phase 3 - for each AG...
        - scan and clear agi unlinked lists...
        - 11:45:46: scanning agi unlinked lists - 16 of 16 allocation groups done
        - process known inodes and perform inode discovery...
        - agno = 15
        - agno = 0
        - agno = 1
        - agno = 2
        - agno = 3
        - agno = 4
        - agno = 5
        - agno = 6
        - agno = 7
        - agno = 8
        - agno = 9
        - agno = 10
        - agno = 11
        - agno = 12
data fork in ino 50509395 claims free block 6314488
data fork in ino 50509395 claims free block 6314489
        - agno = 13
data fork in ino 54549341 claims free block 6818903
data fork in ino 54549341 claims free block 6818904
data fork in ino 54549341 claims free block 6818937
data fork in ino 54549341 claims free block 6818938
        - agno = 14
        - 11:45:47: process known inodes and inode discovery - 103104 of 103104 inodes done
        - process newly discovered inodes...
        - 11:45:47: process newly discovered inodes - 16 of 16 allocation groups done
Phase 4 - check for duplicate blocks...
        - setting up duplicate extent list...
        - 11:45:47: setting up duplicate extent list - 16 of 16 allocation groups done
        - check for inodes claiming duplicate blocks...
        - agno = 0
        - agno = 1
        - agno = 2
        - agno = 3
        - agno = 4
        - agno = 5
        - agno = 6
        - agno = 7
        - agno = 8
        - agno = 9
        - agno = 10
        - agno = 11
        - agno = 12
        - agno = 13
        - agno = 14
        - agno = 15
        - 11:45:47: check for inodes claiming duplicate blocks - 103104 of 103104 inodes done
Phase 5 - rebuild AG headers and trees...
        - 11:45:47: rebuild AG headers and trees - 16 of 16 allocation groups done
        - reset superblock...
Phase 6 - check inode connectivity...
        - resetting contents of realtime bitmap and summary inodes
        - traversing filesystem ...
        - traversal finished ...
        - moving disconnected inodes to lost+found ...
disconnected inode 54549341, moving to lost+found
Phase 7 - verify and correct link counts...
        - 11:45:47: verify and correct link counts - 16 of 16 allocation groups done
done

・リカバリ用 VM からディスクをデタッチします。

VM インスタンス > sanuki-recover01 > 編集 > 追加ディスク : 既存のディスク > sanuki-dest-wd01 : デタッチ > 保存

・リペアしたディスクからもう一度 VM を起動します。

VM インスタンス > インスタンスの作成 > ブートディスク : 既存のディスク : sanuki-dest-wd01 : 選択 > 作成

コンソールに接続します。
…接続できました。

serialport: Connected to fleet-acumen-286514.asia-northeast1-b.sanuki-dest-wd01 
port 1 (session ID: 99e0fefe92785865dd201bdc6865a89ae461aa77, active connections
: 1).
[   31.822303] APL|18307:18410:transport.c:1112:transport_device_release| INFO: 
detaching interceptors
[   31.838278] APL|18307:18410:tracepoints.c:99:tracepoints_detach| INFO: tracep
oints detached
[   31.847363] APL|18307:18410:syscall_common.c:149:detach_hooks| INFO: detachin
g syscall hooks...
[   31.856628] APL|18307:18410:syscall_common.c:186:detach_hooks| INFO: syscall 
hooks detached
[   31.865252] APL|18307:18410:syscall_common.c:213:syscall_hooks_detach| INFO: 
module_refcount()=1
[   31.874511] APL|18307:18410:transport.c:1117:transport_device_release| INFO: 
interceptors detached
[   32.305021] snapapi_init(modprobe,18440): Snapapi(v.0.8.8) init OK. Session s
ize 13784. Em size 990. Ctl major 245
[   32.351295] device-mapper: uevent: version 1.0.3
[   32.356843] device-mapper: ioctl: 4.37.1-ioctl (2018-04-03) initialised: dm-d
evel@redhat.com
[   32.390383] BIOS EDD facility v0.16 2004-Jun-25, 1 devices found
CentOS Linux 7 (Core)
Kernel 3.10.0-1062.12.1.el7.x86_64 on an x86_64

sanuki-dest-wd01 login: root
Password: 
Last login: Tue Feb 15 07:57:24 on pts/0
[root@sanuki-dest-wd01 ~]#

4. 終わりに

色々と躓きましたが、サーバの移設自体は可能なようです。
移設元が稼働していてデータの差分等が発生するのであれば、適宜 rsync 等でデータを同期してあげればよいですね。
一から移設先のサーバを作ってデータを同期するよりもお手軽かもしれません。

【SNMP】標準 MIB で CPU / メモリ / Disk の使用率を計算するスクリプトを作る

こんにちは。
とあるネットワーク機器をモニタリングする必要があり、SNMP を用いたリソース情報の取得諸々について調べてみたので備忘録を残しておきます。

※ というのも普段 Linux デバイスに定義されている UCD-SNMP-MIB が定義されていなかったため、どうすればいいんだろう?と悩んだ結果です。 SNMPv2 前提です。

メモリ/ディスク

メモリ / ディスク の情報については、hrStorageDescr の OID で一覧と割り当てられた index 番号を取得できるようです。

[sanuki@sanuki-wd01 ~]$ snmpwalk -v 2c -c <CommunityName> xxx.xxx.xxx.xxx hrStorageDescr
HOST-RESOURCES-MIB::hrStorageDescr.1 = STRING: Physical memory
HOST-RESOURCES-MIB::hrStorageDescr.3 = STRING: Virtual memory
HOST-RESOURCES-MIB::hrStorageDescr.6 = STRING: Memory buffers
HOST-RESOURCES-MIB::hrStorageDescr.7 = STRING: Cached memory
HOST-RESOURCES-MIB::hrStorageDescr.8 = STRING: Shared memory
HOST-RESOURCES-MIB::hrStorageDescr.10 = STRING: Swap space
HOST-RESOURCES-MIB::hrStorageDescr.35 = STRING: /dev/shm
HOST-RESOURCES-MIB::hrStorageDescr.37 = STRING: /run
HOST-RESOURCES-MIB::hrStorageDescr.38 = STRING: /sys/fs/cgroup
HOST-RESOURCES-MIB::hrStorageDescr.55 = STRING: /
HOST-RESOURCES-MIB::hrStorageDescr.63 = STRING: /run/user/1000

※ 上記だと、index 1 が物理メモリ、index 55 がディスクの ルートパーティション となります。

index を参考に下記の OID を取得することで、Total / 使用量 / 使用率 が割り出せます。

HOST-RESOURCES-MIB::hrStorageAllocationUnits
HOST-RESOURCES-MIB::hrStorageSize
HOST-RESOURCES-MIB::hrStorageUsed

※ 計算式は下記の通りです。
Total = HOST-RESOURCES-MIB::hrStorageSize * HOST-RESOURCES-MIB::hrStorageAllocationUnits
使用量 = HOST-RESOURCES-MIB::hrStorageUsed * HOST-RESOURCES-MIB::hrStorageAllocationUnits
使用率 = 使用量 / Total * 100

諸々を bash と awk でスクリプトに纏めると下記の通りとなります。
※ シェルスクリプトだと小数点が扱えないので、演算部分は awk で賄います。
※ 第一引数に求める値(Total/使用量/使用率)、第二引数に領域の index を指定します。

#!/usr/bin/bash

# MEMO: 引数チェック
if [ $# != 2 ]; then
    echo "ERR:invalid argument"
    exit 1
fi

COMMUNITY_NAME=<CommunityName>
IP_ADDR=xxx.xxx.xxx.xxx

ALLOCATIONUNITS=`snmpwalk -v 2c -c ${COMMUNITY_NAME} ${IP_ADDR} hrStorageAllocationUnits | grep "HOST-RESOURCES-MIB::hrStorageAllocationUnits.${2} = INTEGER:" | awk '{print $4}'`
HRSTORAGESIZE=`snmpwalk -v 2c -c ${COMMUNITY_NAME} ${IP_ADDR} hrStorageSize | grep "HOST-RESOURCES-MIB::hrStorageSize.${2} = INTEGER:" | awk '{print $4}'`
HRSTORAGEUSED=`snmpwalk -v 2c -c ${COMMUNITY_NAME} ${IP_ADDR} hrStorageUsed | grep "HOST-RESOURCES-MIB::hrStorageUsed.${2} = INTEGER:" | awk '{print $4}'`

USED_MEMORY=$(( HRSTORAGEUSED * ALLOCATIONUNITS ))
ALL_MEMORY=$(( HRSTORAGESIZE * ALLOCATIONUNITS ))

USED_RATIO=`awk "BEGIN { print $USED_MEMORY / $ALL_MEMORY * 100 }"`

case ${1} in
    # NOTE: Total
    "1")
        echo ${ALL_MEMORY}
        ;;
    # NOTE: Used
    "2")
        echo ${USED_MEMORY}
        ;;
    # NOTE: Ratio
    "3")
        echo ${USED_RATIO}
        ;;
    # NOTE: Ratio
    *)
        echo ${USED_RATIO}
        ;;
esac

CPU

CPU 使用率は hrProcessorLoad の OID で1コア辺りの 使用率を参照できます。

[sanuki@sanuki-wd01 ~]$ snmpwalk -v 2c -c <CommunityName> xxx.xxx.xxx.xxx hrProcessorLoad
HOST-RESOURCES-MIB::hrProcessorLoad.196608 = INTEGER: 2
HOST-RESOURCES-MIB::hrProcessorLoad.196609 = INTEGER: 2
HOST-RESOURCES-MIB::hrProcessorLoad.196610 = INTEGER: 2
HOST-RESOURCES-MIB::hrProcessorLoad.196611 = INTEGER: 2
HOST-RESOURCES-MIB::hrProcessorLoad.196612 = INTEGER: 2
HOST-RESOURCES-MIB::hrProcessorLoad.196613 = INTEGER: 2
HOST-RESOURCES-MIB::hrProcessorLoad.196614 = INTEGER: 2
HOST-RESOURCES-MIB::hrProcessorLoad.196615 = INTEGER: 2

※ 上記だとノードに 8コアの CPU が詰まれていて、1コア辺り 2% の使用率となる。

こちらも bash と awk でスクリプトに纏めると下記の通りとなります。
※ 1 コア辺りの平均値を求めます。

#!/usr/bin/bash

COMMUNITY_NAME=<CommunityName>
IP_ADDR=xxx.xxx.xxx.xxx

TOTAL_CPU_LOAD=`snmpwalk -v 2c -c ${COMMUNITY_NAME} ${IP_ADDR} hrProcessorLoad | awk '{print $4}' | awk '{s += $1} END {print s}'`
CPU_NUM=`snmpwalk -v 2c -c ${COMMUNITY_NAME} ${IP_ADDR} hrProcessorLoad | wc -l`

echo $(( TOTAL_CPU_LOAD / CPU_NUM ))

終わりに

上 2つのスクリプトで対象の機器に対してポーリング → DB にデータ格納 でモニタリングできますね。

SAM + dynamodb-local でローカルにサーバレス開発環境を作って遊んでみる

こんにちは。
最近、DynamoDB について触る機会があったので、少し勉強してみようかな、と。
調べてみると ローカルに DynamoDB を立てることができる dynamodb-local というものがあるそうです。
SAM と dynamodb-local を使ってローカルにサーバレス開発環境を作り、簡単な掲示板を作成してみようと思います。

構成

JavaScript:クライアント
SAM ( ApiGateWay / Lambda ) : バックエンド API
dynamodb-local:データストア
サーバ IP:192.168.33.55

よくあるタイプのやつですね。
SAM ( ApiGateWay / Lambda ) でデータ取得 / データ登録 の REST API を作って、 JavaScript で操作、データは dynamodb-local に保存します。

環境構築

dynamodb-local は Docker のイメージが公開されています。
CentOS7 に SAM と dynamodb-local をセットアップする Ansible の role を こちら に用意しました。

環境構築後は、事前に dynamodb-local にテーブルを作成しておきます。

# テーブル作成
aws dynamodb create-table --table-name 'bbs' \
--attribute-definitions '[{"AttributeName":"postId","AttributeType": "S"}]' \
--key-schema '[{"AttributeName":"postId","KeyType": "HASH"}]' \
--provisioned-throughput '{"ReadCapacityUnits": 5,"WriteCapacityUnits": 5}' \
--endpoint-url http://localhost:8000

バックエンド API

データ取得 / データ登録 の API を Python で作成します。
ディレクトリ構成は下記の通りです。

.
├── bbs
│ ├── create
│ │ └── app.py
│ └── read
│   └── app.py
├── template.yaml
└── vars.json

・データ取得 API ( ./bbs/read/app.py )

import json
import boto3
import time
import logging
import os
from botocore.exceptions import ClientError

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):

    logger.info("Event: " + str(event))

    responseHeaders = {
      "Access-Control-Allow-Methods": "OPTIONS,GET",
      "Access-Control-Allow-Headers" : "*",
      "Access-Control-Allow-Origin": "*"
    }

    try:
        table = _get_database().Table('bbs')
        res = table.scan()
    except ClientError as e:
        logger.error("Error: %s", e)

    return {
        "headers": responseHeaders,
        "statusCode": 200,
        "body":  json.dumps(res['Items'], default=decimal_default_proc),
    }

def decimal_default_proc(obj):
    from decimal import Decimal
    if isinstance(obj, Decimal):
        return float(obj)
    raise TypeError

def _get_database():
    if (os.environ["DYNAMO_ENDPOINT"] == ""):
        endpoint = boto3.resource('dynamodb')
    else:
        endpoint = boto3.resource('dynamodb', endpoint_url=os.environ["DYNAMO_ENDPOINT"])
    return endpoint

・データ登録 API ( ./bbs/create/app.py )

import json
import boto3
import time
import datetime
import logging
import uuid
import os
from boto3.dynamodb.conditions import Key, Attr
from botocore.exceptions import ClientError

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def lambda_handler(event, context):

    logger.info("Event: " + str(event))

    responseHeaders = {
      "Access-Control-Allow-Methods": "OPTIONS,GET,POST",
      "Access-Control-Allow-Headers" : "*",
      "Access-Control-Allow-Origin": "*"
    }

    req = json.loads(event['body'])

    item = {
        'postId': str(uuid.uuid4()),
        'time': str(datetime.datetime.now()),
        'owner': req['owner'],
        'note': req['note'],
        'enable': bool('True')
    }

    logger.info("Item: " + str(item))

    try:
        table = _get_database().Table('bbs')
        res = table.put_item(Item=item)
        logger.info("Respons: " + str(res))
    except ClientError as e:
        logger.error("Error: %s", e)

    return {
        "headers": responseHeaders,
        "statusCode": 200,
        "body": item,
    }

def _get_database():
    if (os.environ["DYNAMO_ENDPOINT"] == ""):
        endpoint = boto3.resource('dynamodb')
    else:
        endpoint = boto3.resource('dynamodb', endpoint_url=os.environ["DYNAMO_ENDPOINT"])
    return endpoint

・環境変数定義ファイル : DynamoDB への接続先エンドポイント ( ./vars.json )

{
    "Parameters": {
      "DYNAMO_ENDPOINT": "http://192.168.33.55:8000"
    }
}

・SAM の template ファイル : API の仕様やその他諸々 ( ./template.yaml )

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: BBS

Globals:
  Function:
    Timeout: 3
    Environment:
      Variables:
        DYNAMO_ENDPOINT: DYNAMO_ENDPOINT
  Api:
    Cors:
      AllowMethods: "'OPTIONS,GET,POST'"
      AllowHeaders: "'*'"
      AllowOrigin: "'*'"

Resources:
  ReadBBSFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: bbs/read/
      Handler: app.lambda_handler
      Runtime: python3.9
      Events:
        ReadBBS:
          Type: Api
          Properties:
            Path: /bbs/
            Method: get

  CreateBBSFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: bbs/create/
      Handler: app.lambda_handler
      Runtime: python3.9
      Events:
        CreateBBS:
          Type: Api
          Properties:
            Path: /bbs/
            Method: post

API の起動

コードをビルドして API を起動します。
※ デフォルトは 3000 ポートで立ち上がります。

sam build
sam local start-api --env-vars vars.json --host 0.0.0.0

curl で API を叩いてエラー無くレスポンスが返ってくれば OK です。
※ 初回リクエスト時にイメージがビルドされるようで、その際レスポンスが少々遅延するようです。
$ curl http://192.168.33.55:3000/bbs
[]

クライアント

Ajax でデータ取得 API を叩いて投稿情報を取得し、一覧を表示します。
併せてデータ登録 API に投稿を POST して登録を行います。

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>bbs</title>
    </head>
    <body>
        <dl id="wrap">
            <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.2.4/jquery.min.js"></script>
            <script type="text/javascript">
            const url = "http://192.168.33.55:3000/bbs";
              $(function(){
                $.getJSON(url, function(note_list){
                  note_list.sort(function(a, b){
                    if (a.time < b.time){
                      return -1;
                    }else{
                      return 1;
                    }
                  });
                  for(var i in note_list){
                    var h = '<dt>'
                          + note_list[i].time
                          + " "
                          + note_list[i].owner
                          + '</dt>'
                          + '<dd>'
                          + note_list[i].note
                          + '</dd>';
                    $("dl#wrap").append(h);
                  }
                });
              });
            </script>
          </dl>
          <p>投稿者: <input type="text" id="owner" size="30"></p>
          <p>NOTE: <input type="text" id="note" size="30"></p>
          <p><button id="button" type="button">投稿</button></p>
          <script type="text/javascript">
            $(function(){
                $("#response").html("Response Values");
        
                $("#button").click( function(){
                    var url = "http://192.168.33.55:3000/bbs";
                        var JSONdata = {
                            "owner": String($("#owner").val()) ,
                            "note": String($("#note").val())     
                        };
        
                    $.ajax({
                        type : 'post',
                        url : url,
                        data : JSON.stringify(JSONdata),
                        contentType: 'application/json',
                        dataType : 'json',
                        scriptCharset: 'utf-8',
                        success : function(time){
                          window.location.reload();
                        },
                        error : function(time){
                          window.location.reload();
                        }
                    });
                })
            })
        </script>
    </body>
</html>

Jenkins + Selenium + Chrome で テストを実行する CI/CD 環境 を作ってみる。

こんにちは。
最近、WSL に Selenium をインストールして遊んでいました。
そこで、ふと思ったのですが、これを CI/CD パイプラインに組み込んだら面白いのではないかと考えました。
Jenkinsの勉強も兼ねて、環境を構築してみることにします。
具体的には、以下のようなパイプラインを作成します。

1. サーバにコンテンツをデプロイする。
2. デプロイ後に Selenium でテストを行う。
3. テスト結果を Slack に通知する。

この一連の流れを自動化し、効率的に運用できる環境を目指します。

Jenkins 構築

CentOS7 に Jenkins / Selenium / Chrome のパッケージを導入します。

yum install wget unzip git
rpm --import https://pkg.jenkins.io/redhat-stable/jenkins.io.key
yum install epel-release
yum install java-11-openjdk-devel
wget -O /etc/yum.repos.d/jenkins.repo https://pkg.jenkins.io/redhat-stable/jenkins.repo
yum install jenkins
systemctl start jenkins.service
systemctl enable jenkins.service

cat << "EOF" >> /etc/yum.repos.d/google.chrome.repo
[google-chrome]
name=google-chrome
baseurl=http://dl.google.com/linux/chrome/rpm/stable/$basearch
enabled=1
gpgcheck=1
gpgkey=https://dl-ssl.google.com/linux/linux_signing_key.pub
EOF

yum install xorg-x11-server-Xvfb
yum install google-chrome-stable
yum install ipa-gothic-fonts ipa-mincho-fonts ipa-pgothic-fonts ipa-pmincho-fonts

yum install python3
pip3 install selenium

cd /usr/local/src/
wget https://chromedriver.storage.googleapis.com/93.0.4577.15/chromedriver_linux64.zip
unzip chromedriver_linux64.zip
mv chromedriver /usr/local/bin/

Jenkins の UI から Xvfb / Slack Notification のプラグインを導入します。
※ Slack 側で Jenkins CI を予め追加しておいてください。
Jenkinsの管理 > プラグインの管理 > 利用可能 > Xvfb > Download now and install after

Jenkinsの管理 > Global Tool Configuration > Xvfb installation追加 > Name に Xvfb を入力 > Save

Jenkinsの管理 > プラグインの管理 > 利用可能 > Slack Notification > Download now and install after

Jenkinsの管理 > システムの設定 > Slack > Workspace に チームサブドメインを入力

Jenkinsの管理 > システムの設定 > Slack > Credential 追加 > Jenkins > 種類 Secret text 

Secret : インテグレーション用トークン認証情報 ID
ID : 任意

> 保存

デプロイジョブの作成

デプロイはシンプルに、ターゲットサーバに SSH 接続して develop ブランチを git pull するだけとします。
Jenkins は jenkins ユーザとして実行されるため、ホームディレクトリ配下にターゲットサーバに設定した公開鍵と対になる秘密鍵を配置しておきます。

mkdir /var/lib/jenkins/.ssh
vi /var/lib/jenkins/.ssh/id_rsa
chown -R jenkins:jenkins /var/lib/jenkins/.ssh
chmod 700 /var/lib/jenkins/.ssh
chmod 600 /var/lib/jenkins/.ssh/id_rsa

新規ジョブ作成 > フリースタイル・プロジェクトのビルド

から

ビルド手順の追加 > シェルの実行
で下記のワンライナーを実行するだけです。

ssh -oStrictHostKeyChecking=no target@xxx.xxx.xxx.xxx "cd /path/to/hoge; git pull origin develop"

ビルド後の処理の追加 > 他のプロジェクトのビルド
で 後述の テストジョブを指定します。

テストジョブの作成

Jenkins サーバの適当なディレクトリに Selenium のスクリプトを配置しておきます。
※ 今回は個人的に作った以下 WEB ツール上で、適当に画面を偏移するスクリプトを用意しました。
https://github.com/snkk1210/ease

#!/usr/bin/python3

import time
import datetime
from selenium import webdriver

URL='http://xxx.xxx.xxx.xxx'
USER="xxx@localhost"
PASSWD="xxx"

def clickHref(link):
    driver.find_element_by_link_text(link).click()
    time.sleep(1)
    return 0

def clickBtn(xpath):
    driver.find_element_by_xpath(xpath).click()
    time.sleep(1)
    return 0

def inputForm(element, input):
    driver.find_element_by_name(element).send_keys(input)
    time.sleep(1)
    return 0

def dialogboxThrough():
    driver.switch_to_alert().accept()
    time.sleep(1)
    return 0

driver = webdriver.Chrome(executable_path='/usr/local/bin/chromedriver')

driver.get(URL)

# login check

inputForm("email", USER)

inputForm("password", PASSWD)

clickBtn("//button[@class='btn btn-block btn-flat btn-primary']")

# sidemenu check

side_menus = ['Playbooks','Make Playbook','Authentications','Make Auth','Archives','Upload Files','Profile','Members']
for side_menu in side_menus:
    clickHref(side_menu)

# make playbook check

clickHref("Make Playbook")

inputForm("name", "test-playbook" + str(time.time()))

inputForm("private_key", "aaa")

inputForm("inventory", "bbb")

clickBtn("//input[@class='btn btn-success']")

# edit playbook check

clickHref("Playbooks")

clickBtn("//input[@class='btn btn-success']")

inputForm("private_key", "aaa")

inputForm("inventory", "bbb")

clickBtn("//input[@class='btn btn-success']")

# archive playbook check

clickHref("Playbooks")

clickBtn("//input[@class='btn btn-warning']")

# delete playbook check

clickHref("Archives")

clickBtn("//input[@class='btn btn-danger']")

dialogboxThrough()

# exit

time.sleep(5)
driver.quit()

新規ジョブ作成 > フリースタイル・プロジェクトのビルド

から

ビルド環境 > Start Xvfb before the build, and shut it down after.
に チェックを入れます。

ビルド手順の追加 > シェルの実行
で上記のスクリプトを実行するように指定してあげるだけで OK です。

ビルド後の処理の追加 > Slack Notifications
を設定しておけば、ジョブの実行結果を Slack に通知することが可能です。

【jq】AWS WAF で Block されたリクエストの一覧を CSV に整形して出力してみる

こんにちは。
AWS WAF により Block されたリクエストの一覧を月一でリスト化する必要がございました。
最初は API を叩いて Sample request を取得し、その後にログ解析を行おうと思ったのですが、どうも、この API には 5000req/3h の制限があるようで断念…。
調べてみると Kinesis Firehose を経由して S3 に JSON 形式で WAF のログを出力できるようです。
ついては出力された S3 のログを引っ張って jq でリスト化すればいいや、となりました。

シェルスクリプト

cron で月末にスクリプトが実行されるよう設定しておけば、あとは放置で OK ですね。

#!/bin/bash

WORK_DIR=""
THIS_YEAR=`date "+%Y"`
THIS_MONTH=`date "+%m"`
S3_BUCKET=""
PROFILE=""
OUTPUTDIR=""
OUTPUT=${OUTPUTDIR}block_requests-`date +'%FT%R'`.csv

# 作業ディレクトリの作成
mkdir -p ${WORK_DIR}/${THIS_YEAR}/${THIS_MONTH}

# S3 からログのダウンロード
aws s3 cp s3://${S3_BUCKET}/${THIS_YEAR}/${THIS_MONTH}/ ${WORK_DIR}/${THIS_YEAR}/${THIS_MONTH} --recursive --profile=${PROFILE}

# csv のフィールド名を出力
echo "Request.ClientIP,TerminatingRuleId,Request.Country,Request.URI,Request.httpVersion,Request.Method,Other" >> ${OUTPUT}

# ログの一覧を取得
SERCH_FILES=`find ${WORK_DIR}/${THIS_YEAR}/${THIS_MONTH} -type f -name aws-waf-logs*`

for SERCH_FILE in ${SERCH_FILES}
do
    # json -> csv の変換 / パース / 出力
    cat ${SERCH_FILE} | jq -r ' select( .action == "BLOCK") | [.httpRequest.clientIp, .terminatingRuleId, .httpRequest.country, .httpRequest.uri, .httpRequest.httpVersion, .httpRequest.httpMethod, "#HeadersName", .httpRequest.headers[].name, "#HeadersValue",.httpRequest.headers[].value, "#RuleGroup", .ruleGroupList[].ruleGroupId, "#RuleMatch", .ruleGroupList[].terminatingRule.ruleId, "#RuleAction", .ruleGroupList[].terminatingRule.action] |@csv' \
    >> ${OUTPUT}
done

jq って便利ですね。

【C】ソケットAPIを用いたデータロガープログラムを書いてみる【socket】

こんにちは。
TCP/IP でのソケットを用いたデータ通信の仕組みに関して改めて勉強してみたので、自分自身の理解のために簡単なデータロガープログラムを書いてみました。
※ いつも通りエラーハンドリングは 無視 省力です。

client.c

まずはクライアント側ですね。
socket() でソケットを作成した後に、connect() を使えばコネクションが張れるようです。
後は生成したソケットを使って、send() でサーバ側にデータを送るだけです。
送信するデータは fgets() で標準入力から取得します。

#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void){
    int sockfd;
    char mes[64];
    struct sockaddr_in server;

    server.sin_family = AF_INET;
    server.sin_port = htons(8888);
    server.sin_addr.s_addr = inet_addr("127.0.0.1");

    sockfd = socket(AF_INET, SOCK_STREAM, 0);

    connect(sockfd, (struct sockaddr *)&server, sizeof(server));

    printf("INPUT:");
    fgets(mes, sizeof(mes), stdin);

    send(sockfd, mes, 64, 0);

    close(sockfd);

    return 0;
}

接続先サーバの情報は sockaddr_in 構造体を使って socket に紐づけます。
※ sockaddr にキャストする必要アリです。
例では localhost の 8888 ポートに接続しています。

server.c

次はサーバ側ですね。
サーバは bind() を使って ソケットに IPアドレス/ポート番号を紐づけ、listen() で割り当てたポート番号に接続を作成できることをシステムに伝えます。
※ 例では最大5つの接続を受け持ちます。( 後段の処理が完了するまで最大5つまでの接続要求がキューに入ります )
実際のデータの送受信にはこのソケットは用いず、accept() を使ってデータ送受信用のソケットを生成して処理を委任します。
後は recv() を使ってデータを受信し、ファイルにデータを書き込み データ送受信用のソケットをクローズします。
※ 36行目には到達しないです。

#include <stdio.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void){
    int sock, sockfd, sock_size;
    char mes[64];
    struct sockaddr_in server;
    struct sockaddr_in client;
    FILE *file;

    server.sin_family = AF_INET;
    server.sin_port = htons(8888);
    server.sin_addr.s_addr = INADDR_ANY;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);

    bind(sockfd, (struct sockaddr *)&server, sizeof(server));

    listen(sockfd, 5);

    while(1){
        sock_size = sizeof(client);
        sock = accept(sockfd, (struct sockaddr *)&client, &sock_size);

        recv(sock, mes, 64, 0);
        file = fopen("loger.txt", "a");
        fprintf(file, mes);
        fclose(file);
        
        close(sock);
    }
    close(sockfd);
    return 0;
}

クライアントと同じく、受け持つIPアドレス/ポート番号を sockaddr_in 構造体を使って socket に紐づけます。
こちらも sockaddr にキャストする必要アリです。
※ INADDR_ANY は全ての接続元( 0.0.0.0 )を表しています。

まとめると下記の形になりますね。

1.socket() を実行して TCPソケットを作成する。
2.bind() を実行してソケットにポート番号(IPアドレスも)を割り当てる。
3.listen() を実行し割り当てたポート番号へ接続を作成できることをシステムに伝える。
4.以下繰り返し
・接続要求を受け取るたびに、accept() を呼び出して新規ソケットを取得
・作成したソケットを介してクライアント(接続要求元)とやり取り
・close() でクライアントとの接続をクローズ

【PHP】Chatwork 通知用の Proxy API を作ってみる

こんにちは。
とある案件でお客様 Zabbix サーバから Chatwork にアラートを通知する必要がございました。
通常であれば、Chatwork の API を叩くスクリプトを用意すればよいのですが、お客様管理の Zabbix サーバですので、セキュリティ的な要因から Chatwork の APIトークンを使うことができません。
ついては、自社サーバに API トークンを配置した Proxy API を用意してみます。

コード

前回は cURL関数 を使ったので、今回は file_get_contents を使ってみます。

<?php
/*
$api_key = "";
$password = "";
*/
include('../../.env');

// リクエストの body を取得
$json = file_get_contents("php://input");
$contents = json_decode($json, true);

// POST されたデータが正しいか判定
if (isset($contents['cw_endpoint']) && is_numeric($contents['cw_endpoint']) && isset($contents['message']) && isset($contents['auth_id'])){
    $cw_endpoint = $contents['cw_endpoint'];
    $message = $contents['message'];
    $auth_id = $contents['auth_id'];
    } else {
        echo "Bad Request.\n";
        http_response_code(400);
        exit();
    }

// 認証情報が正しいか判定 
if ($password !== $auth_id){
    echo "Forbidden.\n";
    http_response_code(403);
    exit();
}

// HTTP ヘッダ
$header = array(
        "Content-Type: application/x-www-form-urlencoded",
        "X-ChatWorkToken: $api_key",
);

// HTTP リクエスト
$context = array(
        "http" => array(
                "method"  => "POST",
                "header"  => implode("\r\n", $header),
                "content" => http_build_query(array(
                    'body' => $message,
                )),
        )
);

// ChatWork API にリクエスト送信
$scc = stream_context_create($context);
$url = "https://api.chatwork.com/v1/rooms/$cw_endpoint/messages";
$res = file_get_contents($url, false, $scc);
echo $res . "\n";

APIトークン と 認証情報は外部より参照できないように、ドキュメントルートより上位に配置しておきます。

<?php
$api_key = "";
$password = "";
?>

使用例

POSTメソッド で HTTPリクエストを投げれば OK です。

・パラメータ
cw_endpoint:通知するルームID
message:通知内容
auth_id:xxxxxx

・curl の例

curl -X POST -d '{"cw_endpoint":"xxxxxxx", "message":"これはテスト", "auth_id":"xxxxxxxxxxxxxx"}' https://xxx.xxx.xxx.xxx/api/proxy.php

【PHP】Cookie を保存/送信して認証が必要なページをスクレイピングしてみる【curl】

こんにちは。
先日、CakePHP で作られた外部システムからデータを引っ張ってくる必要がありました。
ただ、データ取得用の API 等が用意されているわけでもなかったため、curl で叩いてデータを引っ張ってくることにします。

最初のログインページの認証後に Cookie を維持する必要があったので、その処理を PHP で実装してみます。

コード

PHPには cURL 関数が用意されているのでありがたく使わせて頂きます。

<?php

class PHPCurl {
    
    private $getCookieUrl;
    private $targetUrl;
    private $username;
    private $password;
    private $saveCookieFile;

    /**
     * コンストラクタ
     * 
     * @param string $getCookieUrl Cookie取得用のURL
     * @param string $targetUrl    CurlのターゲットURL
     * @param string $username     ログイン情報(ユーザ名)
     * @param string $password     ログイン情報(パスワード)
     */
    public function __construct($getCookieUrl, $targetUrl, $username, $password){
        $this->getCookieUrl = $getCookieUrl;
        $this->targetUrl = $targetUrl;
        $this->username = $username;
        $this->password = $password;
        $this->saveCookieFile = stream_get_meta_data($fp = tmpfile());
    }

    /**
     * Cookie取得用のメソッド
     */
    public function getCookie(){
        $data = array(
            'data[User][username]' => $this->username,
            'data[User][password]' => $this->password,
        );

        $curl = curl_init($this->getCookieUrl);
        curl_setopt($curl, CURLOPT_POST, TRUE);
        curl_setopt($curl, CURLOPT_POSTFIELDS, http_build_query($data));

        curl_setopt($curl, CURLOPT_COOKIEJAR, $this->saveCookieFile['uri']);
        curl_setopt($curl, CURLOPT_COOKIEFILE, $this->saveCookieFile['uri']);
        curl_exec($curl);
        curl_close($curl);
    }

    /**
     * コンテンツ取得用のメソッド
     * 
     * @return string HTMLコンテンツ
     */
    public function getContent(){
        $curl = curl_init($this->targetUrl);
        curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($curl, CURLOPT_COOKIEFILE, $this->saveCookieFile['uri']);
        $result = curl_exec($curl);
        curl_close($curl);

        return $result;
    }
}

$curlInfo = new PHPCurl('https://xxxxxx.xxx/login', 'https://xxxxxx.xxx/target', 'finger', 'ease');

$curlInfo->getCookie();
$content = $curlInfo->getContent();

echo $content;

※ POST の bodyデータ は適宜置き換えてくださいね。
あとは必要なデータをパースするだけです。

おまけ

curl だったら下記 2 行だけなんですよね。

curl -c ./cookie.txt -d "data[User][username]=finger" -d "data[User][password]=ease" "https://xxxxxx.xxx/login"
curl -b ./cookie.txt "https://xxxxxx.xxx/target"

【EASE】Laravel で Chatwork 通知機能を実装してみる【Ansible】

こんにちは。
先日に Laravel で作った Ansible の管理ツールですが、プロビジョニング時に通知を行う機能が欲しいなー、と。
Slack でもよいのですが、会社で主に使っている Chatwork に通知してみます。

【Ansible】playbookをWEB上で管理/実行できるツールを作ってみた【Laravel】

事前準備

Chatwork の API トークンが必要です。
https://developer.chatwork.com/ja/

環境変数(.env)から API トークンと、通知先エンドポイントを読めるようにしておきます。

config/chatwork.php 
==========================================
<?php

# Chatworkのtokenを環境変数から取得
return [
    'cw-token' => env('CW_TOKEN'),
    'cw-endpoint' => env('CW_ENDPOINT'),
];

メソッド

Laravel では Guzzle HTTP クライアントで簡単に http リクエストを発行できるようです。
ヘッダに API トークン、ボディに通知内容のメッセージを指定するんですが、URL エンコードする必要があるので、リクエストを作成する前に asForm メソッドを呼び出します。
※プロビジョニング実行時、完了時で合計 2 つのメソッドを作成します。

use Illuminate\Support\Facades\Http;

    /**
     * 実行時 Chatwork 通知メソッド
     */
    public static function notify2ChatworkStart($user){
        $token = config('chatwork.cw-token');
        $endpoint = config('chatwork.cw-endpoint');
        $date = date("Y/m/d H:i:s");

        $message = "[info][title]EASE Ansible MG started provisioning at " . $date . "[/title]\n" . " by " . $user->name . "[/info]";
        $response = Http::asForm()->withHeaders([
            'X-ChatWorkToken' => $token,
        ])->post($endpoint, [
            'body' => $message
        ]);
        return $response;
    }

    /**
     * 終了時 Chatwork 通知メソッド
     */
    public static function notify2ChatworkEnd($ansible_output){
        $token = config('chatwork.cw-token');
        $endpoint = config('chatwork.cw-endpoint');
        $date = date("Y/m/d H:i:s");

        $message = "[info][title]EASE Ansible MG ended provisioning at " . $date . "[/title]\n" .  $ansible_output . "[/info]";
        $response = Http::asForm()->withHeaders([
            'X-ChatWorkToken' => $token,
        ])->post($endpoint, [
            'body' => $message
        ]);
        return $response;
    }

使い方

環境変数が設定されている時にメソッドを呼び出すだけです。
とてもお手軽。

# Chatworkへの通知(実行時)
if (config('chatwork.cw-token')){ Playbook::notify2ChatworkStart($user); };

# Chatworkへの通知(完了時)
if (config('chatwork.cw-token')){ Playbook::notify2ChatworkEnd($ansible_output[count($ansible_output)-2]); };