Автоконфигурация с помощью Puppet и AWS Cloud Formation

  • Tutorial
imageВот и настал тот день, когда пришлось отложить в сторону кукбуки, рецепты, нож шеф-повара и немного позаниматься кукловодством.
Для начала постановка задачи довольно тривиальная — организовать для девелоперов возможность быстро и просто разворачивать окружение. Обязательное требование — для автоконфигурации использовать Puppet Enterprise
Итак, более подробно об необходимом окружении. Оно будет состоять из двух компонентов, первый — FrontEnd, функции которого выполняет IIS сервер, второй — BackEnd, который будет содержать некий собственно разработаный Worker service и базу MongoDB. Оба компонента, как уже понятно, будут реализованы на Windows Server. Исходники для контента FrontEnd и Worker service будут браться из AWS S3, куда их уже исправно складывает каждую ночь Jenkins.

Создание Cloud Fromation Template

Реализовать Cloud Formation template, который будет стартовать два Windows сервера абсолютно не сложно. Куда интереснее придумать, каким образом сообщить Puppet, какую конфигурацию применять к этим серверам.
В официальной документации Puppet предлагается применять регулярные выражения к хостнейму клиента, что в нашем случае не удобно использовать, так как хостнейм на AWS Amazon выдаётся автоматом и может меняться после стоп-старта инстанса, то есть я был бы вынужден выдумывать пост-старт скрипт, который должен менять хостнейм машины и только потом стартовать puppet agent.
Покопавшись еще в документации, я нашёл то, что надо — Custom External Facts. Для тех, кто работает c Chef Server, facts — это аналог attributes.
Чтобы добавить свои факты для виндоус машины, необходимо создать bat или ps1 файл примерно следующего содержания и положить его в "C:\ProgramData\PuppetLabs\facter\facts.d\".
@echo off
echo node_role=frontend
echo app_version=Build1.2.0

Где serverRole — это, как понятно из названия, роль, которая будет назначена серверу, а buildNumber — это версия приложения, которая будет скачана с S3 AWS.
Создавать этот файл будет Cloud Formation template.
DevEnv.tmpl
{
    "AWSTemplateFormatVersion" : "2010-09-09",

    "Description" : "Developers Stack",
	"Parameters" : {
		"KeyName" : {
			"Description" : "Key-pair name",
			"Type" : "String"
		},
		"SuffixName" : {
			"Description" : "Suffix for all created resources",
			"Type" : "String"
		},
		"FrontEndInstanceType" : {
			"Type" : "String",
			"Default" : "m1.small",
			"AllowedValues" : [ "m1.small", "m1.medium", "m1.large", "m1.xlarge"],
			"Description" : "EC2 instance type"
		},
		"BackEndInstanceType" : {
			"Type" : "String",
			"Default" : "m1.small",
			"AllowedValues" : [ "m1.small", "m1.medium", "m1.large", "m1.xlarge"],
			"Description" : "EC2 instance type"
		},
	    "PuppetServer": {
			"Description" : "Puppet Server URL",
			"Type" : "String",
			"Default" : "ec2-231-231-123-123.us-west-2.compute.amazonaws.com"
		},
		"Zone" : {
			"Type" : "CommaDelimitedList",
			"Description" : "The Availability Zone ",
			"Default" : "us-west-2c"
		},
		"BuildVersion" : {
			"Type" : "String",
			"Description" : "Version of application build"
		},
		"RoleName" : {
			"Type" : "String",
			"Description" : "Instance IAM role",
			"Default" : "WebInstance"
		},
		"SecurityGroup" : {
			"Type" : "String",
			"Description" : "Default security group for stack",
			"Default" : "taws-security-group"
		}
	},
	"Mappings" : {
		"WindowsInstanceType" : {
		  "t1.micro"    : { "Arch" : "64" },
		  "m1.small"    : { "Arch" : "64" },
		  "m1.medium"   : { "Arch" : "64" },
		  "m1.large"    : { "Arch" : "64" },
		  "m1.xlarge"   : { "Arch" : "64" }
		},

		"WindowsRegionMap" : {
		  "us-east-1"      : { "AMI" : "ami-e55a7e8c" },
		  "us-west-2"      : { "AMI" : "ami-1e53c82e" },
		  "us-west-1"      : { "AMI" : "ami-b687b1f3" },
		  "eu-west-1"      : { "AMI" : "ami-5f3ad728" },
		  "ap-southeast-1" : { "AMI" : "ami-96cd98c4" },
		  "ap-southeast-2" : { "AMI" : "ami-ab4a2daa" },
		  "ap-northeast-1" : { "AMI" : "ami-133fa329" },
		  "sa-east-1"      : { "AMI" : "ami-bd3d9ba0" }
		}
	},
	"Resources" : {
		"FrontEnd" : {
		"Type" : "AWS::EC2::Instance",
		"Properties" : {
			"KeyName" : { "Ref" : "KeyName" },
			"ImageId" : { "Fn::FindInMap" : [ "WindowsRegionMap", { "Ref" : "AWS::Region" }, "AMI" ]},
			"InstanceType" : { "Ref" : "FrontEndInstanceType" },
			"IamInstanceProfile" : { "Ref" : "RoleName" },
			"SecurityGroups" : [{ "Ref" : "SecurityGroup" }],
			"Tags" : [
                {"Key" : "Name", "Value" : { "Fn::Join" : ["",[{"Ref" : "SuffixName"},"-DEV-FrontEnd"]]}}
				],
			"UserData" : { "Fn::Base64" : { "Fn::Join" : ["", [
				  "<powershell>\n",
					"$MsiUrl = \"https://s3-us-west-2.amazonaws.com/mybucket/puppet.msi\"\n",
					"$downloadPath = \"c:\\puppet.msi\"\n",
					"$webClient = New-Object System.Net.WebClient\n",
					"$webClient.DownloadFile($MsiUrl, $downloadPath)\n",
					"$process = Start-Process -File $downloadPath -arg \"/qn /norestart\" -PassThru |wait-process\n",
					
					"$PublicHostName = Invoke-RestMethod -Uri http://169.254.169.254/latest/meta-data/public-hostname -Method Get\n",
					"Clear-Content 'C:\\ProgramData\\PuppetLabs\\puppet\\etc\\puppet.conf'\n",
					"Add-Content 'C:\\ProgramData\\PuppetLabs\\puppet\\etc\\puppet.conf' \"[main]\", \"runinterval=300\", \"certname=$PublicHostName\", \"server=",{ "Ref" : "PuppetServer" },"\", \"environment=",{ "Ref" : "PuppetEnvironment" },"\"\n",
					"Add-Content 'C:\\ProgramData\\PuppetLabs\\facter\\facts.d\\facts.bat' \"@echo off\", \"echo node_role=frontend\", \"echo app_version=",{ "Ref" : "BuildVersion" },"\"\n",
					"Restart-Service pe-puppet\n",
					
					"$MsiUrl = \"https://s3-us-west-2.amazonaws.com/mybucket/7zip.msi\"\n",
					"$downloadPath = \"c:\\7zip.msi\"\n",
					"$webClient = New-Object System.Net.WebClient\n",
					"$webClient.DownloadFile($MsiUrl, $downloadPath)\n",
					"$process = Start-Process -File $downloadPath -arg \"/qn \" -PassThru |wait-process\n",
					
				  "</powershell>\n"
			]]}}
			}
		},
		"BackEnd" : {
		"Type" : "AWS::EC2::Instance",
		"Properties" : {
			"KeyName" : { "Ref" : "KeyName" },
			"ImageId" : { "Fn::FindInMap" : [ "WindowsRegionMap", { "Ref" : "AWS::Region" }, "AMI" ]},
			"InstanceType" : { "Ref" : "BackEndInstanceType" },
			"IamInstanceProfile" : { "Ref" : "RoleName" },
			"SecurityGroups" : [{ "Ref" : "SecurityGroup" }],
			"Tags" : [
                {"Key" : "Name", "Value" : { "Fn::Join" : ["",[{"Ref" : "SuffixName"},"-DEV-BackEnd"]]}}
				],
			"UserData" : { "Fn::Base64" : { "Fn::Join" : ["", [
				  "<powershell>\n",
					"$MsiUrl = \"https://s3-us-west-2.amazonaws.com/mybucket/puppet.msi\"\n",
					"$downloadPath = \"c:\\puppet.msi\"\n",
					"$webClient = New-Object System.Net.WebClient\n",
					"$webClient.DownloadFile($MsiUrl, $downloadPath)\n",
					"$process = Start-Process -File $downloadPath -arg \"/qn /norestart\" -PassThru |wait-process\n",
					
					"$PublicHostName = Invoke-RestMethod -Uri http://169.254.169.254/latest/meta-data/public-hostname -Method Get\n",
					"Clear-Content 'C:\\ProgramData\\PuppetLabs\\puppet\\etc\\puppet.conf'\n",
					"Add-Content 'C:\\ProgramData\\PuppetLabs\\puppet\\etc\\puppet.conf' \"[main]\", \"runinterval=300\", \"certname=$PublicHostName\", \"server=",{ "Ref" : "PuppetServer" },"\", \"environment=",{ "Ref" : "PuppetEnvironment" },"\"\n",
					"Add-Content 'C:\\ProgramData\\PuppetLabs\\facter\\facts.d\\facts.bat' \"@echo off\", \"echo node_role=backend\", \"echo app_version=",{ "Ref" : "BuildVersion" },"\"\n",
					"Restart-Service pe-puppet\n",
					
					"$MsiUrl = \"https://s3-us-west-2.amazonaws.com/mybucket/7zip.msi\"\n",
					"$downloadPath = \"c:\\7zip.msi\"\n",
					"$webClient = New-Object System.Net.WebClient\n",
					"$webClient.DownloadFile($MsiUrl, $downloadPath)\n",
					"$process = Start-Process -File $downloadPath -arg \"/qn \" -PassThru |wait-process\n",					
				  "</powershell>\n"
			]]}}
			}
		}
	},
	"Outputs" : {
		"FrontEndPublicDnsName" : {
				"Description" : "Public IP address of FrontEnd",
				"Value" :  { "Fn::Join" : ["",[{ "Fn::GetAtt" : [ "FrontEnd", "PublicDnsName" ] }]]} 
			},
		"BackEndPublicDnsName" : {
				"Description" : "Public IP address of BackEnd",
				"Value" :  { "Fn::Join" : ["",[{ "Fn::GetAtt" : [ "BackEnd", "PublicDnsName" ]}]]} 
			}			
	}	
}



Параметры, которые используются в темплейте:
  • KeyName — Имя ключа для доступа
  • SuffixName — Некий суффикс, который будет добавлен а тэг Name (это могут быть инициалы девелопера)
  • FrontEndInstanceType — Тип шейпа для FrontEnd
  • BackEndInstanceType — Тип шейпа для BackEnd
  • PuppetServer — Url вашего Puppet сервера
  • Zone — Зона, в которой будут созданы сервера
  • BuildVersion — Версия приложения, которая будет взята с S3
  • RoleName — заранее создання IAM Role с правами «S3 Read-Only»
  • SecurityGroup — Также заранее созданная секьюрити группа

IAM Role и Security Group могут создаваться этим же темплейтом, это будет даже правильнее. В моей примере это не делается с целью упрощения понимания.
В разделе UserData выполняется скачивание и установка puppet agent, 7zip и формируются puppet.conf и facts.bat.
С Cloud Formation закончили, пора переходить к настройке Puppet.

Настройка Puppet Server Enterprise

Чтобы установить Puppet Server Enterprise, необходимо только скачать архив установщика, распаковать и запустить puppet-server-installer. Чтобы включить автоматическую регистрацию клиентов на сервере, нужно создать файл /etc/puppetlabs/puppet/autosign.conf следующего содержания:
*

Создадим необходимые модули. Модули, это что-то вроде кукбуков в Chef. Размещаются они в папке /etc/puppetlabs/puppet/modules.
Упрощённая структура модуля:
  • my_module/ — Название директории будет названием модуля.
    • manifests/ — Содержит манифесты модуля.
      • init.pp — Содержит один класс my_module. Название класса должно быть таким же как название модуля.
      • other_class.pp — Содержит еще один класс модуля my_module::other_class.
    • files/ — Содержит файлы, которые будут скачаны клиентом
    • lib/ — Содержит плагины, кастомные факты
    • templates/ — Содержит темплейты, которые могут быть использованы в модуле
      • component.erb — Этот манифест будет доступен в модуле как template('my_module/component.erb').

Сначала добавим необходимые модули из PuppetLabs для установки IIS и менеджмента.
puppet module install dism
puppet module install opentable-iis

Теперь нужно немного подправить манифест для opentable-iis
/etc/puppetlabs/puppet/modules/nodes/manifests/init.pp
class iis {
iis::manage_app_pool {"${fqdn}":
enable_32_bit => true,
managed_runtime_version => 'v4.0',
} ->

iis::manage_site {"${fqdn}":
site_path => 'C:\MyAppPath',
port => '80',
ip_address => '*',
host_header => "${fqdn}",
app_pool => "${fqdn}"
}

}

У меня получилось семь модулей (может дальше их количество вырастет).
  1. nodes — модуль, который будет, исходя из значения node_role, подключать следующий необходимый модуль
    /etc/puppetlabs/puppet/modules/nodes/manifests/init.pp
    class nodes {
    if "${node_role}" == «backend» {
    include backend
    }
    if "${node_role}" == «frontend» {
    include frontend
    }
    }

  2. getbuild — этот модуль нужен для скачивания и распаковки архива приложения из AWS S3.
    /etc/puppetlabs/puppet/modules/getbuild/manifests/init.pp
    class getbuild {
    file { 'c:\config':
    ensure => 'directory'
    } ->
    file { 'c:\Build':
    ensure => 'directory'
    } ->
    exec { 'download_build':
    creates => «c:\\config\\${app_version}»,
    path => $::path,
    command => «powershell.exe -executionpolicy unrestricted start-bitstransfer -source s3-us-west-2.amazonaws.com/mybucket${app_version} -Destination 'c:\\config\\'»,
    } ->
    exec { 'app_install':
    creates => «c:\\Build\CustomBackendService.exe.config»,
    command => "\«c:\\Program Files\\7-Zip\\7z.exe\» x c:\\config\\${app_version} -oC:\\Build ",
    }

    }

  3. mongodb — модуль для установки MongoDB
    /etc/puppetlabs/puppet/modules/mongodb/manifests/init.pp
    class mongodb {
    file { 'c:/config':
    ensure => directory,
    } ->
    file { 'c:/config/mongodb.zip':
    ensure => file,
    mode => '0777',
    source => 'puppet:///modules/mongodb/mongodb-win32-x86_64-v2.4-latest.zip',
    } ->
    file { 'c:/MongoDB':
    ensure => directory,
    } ->
    file { 'c:/MongoDB/bin':
    ensure => directory,
    } ->
    file { 'c:/MongoDB/Data':
    ensure => directory,
    } ->
    file { 'c:/MongoDB/logs':
    ensure => directory,
    } ->
    exec { 'mongodb-unzip':
    creates => 'c:/MongoDB/bin/mongod.exe',
    command => '«c:\\Program Files\\7-Zip\\7z.exe» e c:\\config\mongodb.zip -oC:\\MongoDB\\bin',
    } ->
    exec { 'mongodb-install':
    creates => 'c:/MongoDB/logs/mongodb.log',
    command => '«c:\\MongoDB\\mongod.exe» --dbpath=c:\\MongoDB\\Data --port 27017 --logpath=c:\\MongoDB\logs\\mongodb.log --install --serviceName mongodb --serviceDisplayName «MongoDB Server» --serviceDescription «MongoDB Server»',
    } ->
    exec { 'mongodb-run':
    path => $::path,
    command => 'powershell.exe start-service mongodb'
    }
    }

  4. api — модуль для установки приложения на FrontEnd
    /etc/puppetlabs/puppet/modules/api/manifests/init.pp
    class api {

    include getbuild

    dism { 'IIS-WebServerRole':
    ensure => present,
    } ->

    dism { 'IIS-WebServer':
    ensure => present,
    require => Dism['IIS-WebServerRole'],
    }

    }

  5. worker — модуль для установки приложения на BackEnd
    /etc/puppetlabs/puppet/modules/worker/manifests/init.pp
    class worker {
    include getbuild
    exec { 'service_install':
    creates => «c:\\Build\\Custom.AWS.BackendService.InstallLog»,
    command => «c:\\Build\\Custom.AWS.BackendService.exe -install»,
    } ->
    exec { 'service-run':
    path => $::path,
    command => 'powershell.exe start-service Custom.AWS.Backend'
    }
    }

  6. frontend — модуль, который подключает все необходимые модули для работы FrontEnd
    /etc/puppetlabs/puppet/modules/frontend/manifests/init.pp
    class frontend {
    include api
    include iis
    }

  7. backend — модуль, который подключает все необходимые модули для работы BackEnd
    /etc/puppetlabs/puppet/modules/backend/manifests/init.pp
    class backend {
    include mongodb
    include worker
    }



В своих манифестах я практически везде использовал ресурс exec. При правильно подобранном параметре creates этот ресурс работает безотказно.
Более детально на одном из примеров:
exec { 'mongodb-unzip':
		creates => 'c:/MongoDB/bin/mongod.exe',
		command => '"c:\\Program Files\\7-Zip\\7z.exe" e c:\\config\mongodb.zip -oC:\\MongoDB\\bin',
	} 

Если исполняемый файл c:/MongoDB/bin/mongod.exe отсутствует, то будет выполнена распаковка архива.

Теперь можно для удобства создать задачу в Вашей любимой CI системе, например Jenkins, поместить туда скрипт для запуска Cloud Formation template и девелоперы смогут разворачивать окружение в один клик.

На этом всё. Надеюсь данное руководство будет полезным…
Если среди прочитавших эту статью, будут спецы по использованию Puppet, я с превеликой благодарностью выслушаю Ваше мнение.
EPAM
Компания для карьерного и профессионального роста

Комментарии 10

    +1
    Есть еще Foreman. Свободная альтернатива PE.

    Кстати, а в puppet, случаем, нельзя администрировать репозитории?(apt, yum)
    +1
    интересно было бы узнать, а с чего Вы решили что, например, mongodb-unzip, install, run будет выполнены именно в такой последовательности? в exec нигде не прописаны зависимости, а манифесты в puppet не являются линейными. хотя желаемого в итоге добиться удастся только после нескольких следующих запусков манифестов, но это, во-первых, неправильно, а во-вторых между запусками может пройти немало времени.
      +1
      вот этот символ "->" указывает на последовательность
        +1
        Интересно, видимо из-за отсутствия подсветки (кстати, поставьте Souce = ruby, заработает) я и не заметил chaining arrows. My bad, как говорится! :-[
      +3
      Вместо распаковки архивов я бы использовал chocolatey — это гораздо более элегантное решение. У puppet существует провайдер package chocolatey. Установка mongodb выглядела бы приблизительно так:
      package { "mongodb":
          ensure => installed,
          provider => chocolatey,
        }
      

      Если не используете deployment систему (для .net очень сильно рекомендую Octopus Deploy) — я бы тогда использовал chocolatey для развертывания своего софта. Пакуете свой софт в nuget-package и устанавливаете этот пакет из своего источник). А учитывая еще то, что в puppet есть модуль iis, то получилось бы примерно следующее:
      class www_site_com {
        include packages::iis
        include iis::certs::star_site_com
        $sitename = 'www.site.com'
      
      
        #Получаем имя сервера для последующего обращения к нему
        $srvsubstname = downcase($::hostname)
      
        #Создаем директорию IIS
        file { "c:\\inetpub\\wwwroot\\$sitename":
          ensure => "directory",
        } 
      
        #Создаем запись в host файле для обращения к серверу по его имени локально
        host { "${srvsubstname}$sitename":
          ip => '127.0.0.1',
        }
      
        #Создаем Application Pool
        iis_apppool {$sitename:
          ensure                => present,
          managedpipelinemode   => 'Integrated',
          managedruntimeversion => 'v4.0',
          autostart             => true,
          require => Class['packages::iis'],
        }
      
        #Создаем Site & Bindings
        iis_site {$sitename:
          ensure          => present,
          bindings        => ["http/*:80:$sitename","http/*:80:${srvsubstname}$sitename","https/*:80:$sitename","https/*:80:${srvsubstname}$sitename"],
          serverautostart     => true,
          require => Class['packages::iis','iis::certs::star_site_com'],
        }
      
        #Создаем Application
        iis_app {"$sitename/":
          ensure          => present,
          applicationpool => $sitename,
          require => Class['packages::iis'],
        }
      
        #Указываем директорию в которой находится наше приложение
        iis_vdir {"$sitename/":
          ensure          => present,
          iis_app         => "$sitename/",
          physicalpath    => "c:\\inetpub\\wwwroot\\$sitename",
          require => [Class['packages::iis'],File["c:\\inetpub\\wwwroot\\$sitename"]],
        }
      
        #Устанавливаем нужное приложение из своего nuget-feed 
        package { "api_deploy":
          ensure => installed,
          provider => chocolatey,
          source  => 'https://myfeed.example.com/api/v2'
        }
       }
      

      Таким образом, все действия по установке приложения api_deploy происходят в chocolateyInstall.ps1 (док), и сам манифест остается чистым.

      Про порядок exec было верно подмечено, у Вас нет ни одной зависимости, и все будет запускаться в разнобой, используйте ordering.

      Очень рекомендую использовать theforeman, так-как в нем не только функционал назначения классов, но еще есть возможность управлять инстансами в AWS прямо из панели управления theForeman (очень удобно создавать инстансы прямо там из заданного образа AMI с нужными классами в один клик!).

      Удачи!
        +1
        Огромное спасибо за комментарий, обязательно попробую такой вариант.
        +1
        Вдохновлённый комменатрием kireevco, подправил свою конфигурацию. Теперь IIS и сайт конфигурируются с помощью модулей от PuppetLabs (dism и opentable-iis).
        Скоро изменения коснутся и других компонентов.
          +1
          Класс, рад что пригодилось!
          0
          В моей инфраструктуре сложность заключалась в том, что несколько кластеров имеют разные версии mongo, и поскольку 2.2 и 2.4 несовместимы между собой приходится иметь разные версии mongos на клиентах. Недавно решил задачу с установкой — создал пару пакетов для chocolatey для версий 2.2.X и 2.4.X. Таким образом устанавливать mongos на клиенты под винду стало еще удобнее:

          class packages::mongodb::install {  
              include packages::chocolatey
          
              if $::kernel == "windows"
              {   
          
                  package { "mongodb.core.2.4":            
                      name => "mongodb.core.2.4",            
                      ensure          => "2.4.9.2014021905",
                      provider        => 'chocolatey',
                      install_options => "-pre",
                      require         => Class["packages::chocolatey"],
                  }
          
                  file {'c:\mongodb\2.4.9\log':
                      ensure => directory,
                      require => Package["mongodb.core.2.4"],
                  }
          
                  package { "mongodb.core.2.2":            
                      name => "mongodb.core.2.2",            
                      ensure          => "2.2.7.2014021905",
                      provider        => 'chocolatey',
                      install_options => "-pre",
                      require         => Class["packages::chocolatey"],
                  }
          
                  file {'c:\mongodb\2.2.7\log':
                      ensure => directory,                                   
                      require => Package["mongodb.core.2.2"],
                  }
              }
          }
          

          Позволяет распаковать mongodb в c:\mongodb\<версия>. После чего, можно установить службу, mongos, например:

          class packages::mongodb::mongos::blah {  
              include packages::mongodb::install
              
              $mongo_version = '2.2.7'
                  
              $cluster_name = 'Blah'    
              $bind_port = '27019'
              $configdb_list = 'cfg1.foo.corp:26001,cfg2.foo.corp:26002,cfg3.foo.corp:26003'
          
              $service_name = "Mongo $cluster_name Router"
              $mongo_install_path = "c:\\mongodb\\$mongo_version"
              $mongos_exe = "$mongo_install_path\\bin\\mongos.exe"
              $service_install_cmd = "\"$mongos_exe --port $bind_port --logpath $mongo_install_path\\log\\mongos-$cluster_name.log --configdb '$configdb_list' --quiet --install --serviceName '$service_name' --serviceDisplayName '$service_name' --serviceDescription '$service_name - Managed by Puppet'\""
          
              exec {"Register $service_name Service" :
                          command => "$service_install_cmd",
                          path => $::path,            
                          onlyif => "if (\$(Get-WmiObject Win32_Service | Select-String -Pattern '$service_name') -eq \$null ){ exit 0 } else { exit 1 }",            
                          provider => powershell,
                          require => [Package["mongodb.core.2.2"],File["$mongo_install_path\\log"]], 
                          notify => Service["$service_name"],
              }
               
              service {"$service_name":
                          ensure => running,
                          enable => true,                
                          require => [ Exec["Register $service_name Service"]],                
              }
          }
          

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое