зеркало из https://github.com/Azure/vagrant-azure.git
start, stop, terminate, read_state and auth good
This commit is contained in:
Родитель
e2b639beb9
Коммит
9411b2da4d
|
@ -10,7 +10,7 @@ module VagrantPlugins
|
|||
end
|
||||
|
||||
def call(env)
|
||||
env[:result] = env[:machine].state.id != :stopped
|
||||
env[:result] = env[:machine].state.id == :stopped
|
||||
@app.call(env)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,11 +2,14 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See License in the project root for license information.
|
||||
require 'log4r'
|
||||
require 'vagrant-azure/util/machine_id_helper'
|
||||
|
||||
module VagrantPlugins
|
||||
module Azure
|
||||
module Action
|
||||
class ReadSSHInfo
|
||||
include VagrantPlugins::Azure::Util::MachineIdHelper
|
||||
|
||||
def initialize(app, env, port = 22)
|
||||
@app = app
|
||||
@port = port
|
||||
|
@ -14,45 +17,16 @@ module VagrantPlugins
|
|||
end
|
||||
|
||||
def call(env)
|
||||
env[:ui].detail "Looking for local port #{@port}"
|
||||
|
||||
env[:machine_ssh_info] = read_ssh_info(
|
||||
env[:azure_arm_service],
|
||||
env
|
||||
)
|
||||
|
||||
env[:ui].detail "Found port mapping #{env[:machine_ssh_info][:port]} --> #{@port}"
|
||||
|
||||
env[:machine_ssh_info] = read_ssh_info(env[:azure_arm_service], env)
|
||||
@app.call(env)
|
||||
end
|
||||
|
||||
def read_ssh_info(azure, env)
|
||||
return nil if env[:machine].id.nil?
|
||||
resource_group_name, vm_name = env[:machine].id.split(':')
|
||||
vm = azure.compute.virtual_machines.get(resource_group_name, vm_name, 'instanceView').value!.body
|
||||
parsed = parse_machine_id(env[:machine].id)
|
||||
public_ip = azure.network.public_ipaddresses.get(parsed[:group], "#{parsed[:name]}-vagrantPublicIP").value!.body
|
||||
|
||||
if vm.nil?
|
||||
# Machine cannot be found
|
||||
@logger.info 'Machine not found. Assuming it was destroyed and cleaning up environment'
|
||||
terminate(env)
|
||||
return nil
|
||||
end
|
||||
|
||||
# vm.tcp_endpoints.each do |endpoint|
|
||||
# if endpoint[:local_port] == "#{@port}"
|
||||
# return { :host => "#{vm.cloud_service_name}.cloudapp.net", :port => endpoint[:public_port] }
|
||||
# end
|
||||
# end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
def terminate(env)
|
||||
destroy_env = env.dup
|
||||
destroy_env.delete(:interrupted)
|
||||
destroy_env[:config_validate] = false
|
||||
destroy_env[:force_confirm_destroy] = true
|
||||
env[:action_runner].run(Action.action_destroy, destroy_env)
|
||||
{:host => public_ip.properties.dns_settings.fqdn, :port => 22}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,32 +2,36 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See License in the project root for license information.
|
||||
require 'log4r'
|
||||
require 'vagrant-azure/util/vm_status_translator'
|
||||
require 'vagrant-azure/util/machine_id_helper'
|
||||
|
||||
module VagrantPlugins
|
||||
module Azure
|
||||
module Action
|
||||
class ReadState
|
||||
include VagrantPlugins::Azure::Util::VMStatusTranslator
|
||||
include VagrantPlugins::Azure::Util::MachineIdHelper
|
||||
|
||||
def initialize(app, env)
|
||||
@app = app
|
||||
@logger = Log4r::Logger.new('vagrant_azure::action::read_state')
|
||||
end
|
||||
|
||||
def call(env)
|
||||
env[:machine_state_id] = read_state(env[:azure_arm_service], env)
|
||||
env[:machine_state_id] = read_state(env[:azure_arm_service], env[:machine])
|
||||
@app.call(env)
|
||||
end
|
||||
|
||||
def read_state(azure, env)
|
||||
machine = env[:machine]
|
||||
def read_state(azure, machine)
|
||||
return :not_created if machine.id.nil?
|
||||
|
||||
# Find the machine
|
||||
rg_name, vm_name = machine.id.split(':')
|
||||
parsed = parse_machine_id(machine.id)
|
||||
vm = nil
|
||||
begin
|
||||
vm = azure.compute.virtual_machines.get(rg_name, vm_name, 'instanceView').value!.body
|
||||
vm = azure.compute.virtual_machines.get(parsed[:group], parsed[:name], 'instanceView').value!.body
|
||||
rescue MsRestAzure::AzureOperationError => ex
|
||||
if vm.nil? || [:'shutting-down', :terminated].include?(vm.state.to_sym)
|
||||
if vm.nil? || tearing_down?(vm.properties.instance_view.statuses)
|
||||
# The machine can't be found
|
||||
@logger.info('Machine not found or terminated, assuming it got destroyed.')
|
||||
machine.id = nil
|
||||
|
@ -36,7 +40,7 @@ module VagrantPlugins
|
|||
end
|
||||
|
||||
# Return the state
|
||||
vm.state.to_sym
|
||||
power_state(vm.properties.instance_view.statuses)
|
||||
end
|
||||
|
||||
end
|
||||
|
|
|
@ -2,22 +2,24 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See License in the project root for license information.
|
||||
require 'log4r'
|
||||
require 'vagrant-azure/util/machine_id_helper'
|
||||
|
||||
module VagrantPlugins
|
||||
module Azure
|
||||
module Action
|
||||
class RestartVM
|
||||
include VagrantPlugins::Azure::Util::MachineIdHelper
|
||||
|
||||
def initialize(app, env)
|
||||
@app = app
|
||||
@logger = Log4r::Logger.new('vagrant_azure::action::restart_vm')
|
||||
end
|
||||
|
||||
def call(env)
|
||||
env[:machine].id =~ /@/
|
||||
|
||||
env[:ui].info "Restarting #{$`} in #{$'}"
|
||||
env[:azure_vm_service].restart_virtual_machine($`, $')
|
||||
|
||||
parsed = parse_machine_id(env[:machine].id)
|
||||
env[:ui].info(I18n.t('vagrant_azure.restarting', parsed))
|
||||
env[:azure_arm_service].compute.virtual_machines.restart(parsed[:group], parsed[:name])
|
||||
env[:ui].info(I18n.t('vagrant_azure.restarted', parsed))
|
||||
@app.call(env)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,6 +6,7 @@ require 'json'
|
|||
require 'azure_mgmt_resources'
|
||||
require 'vagrant/util/template_renderer'
|
||||
require 'vagrant-azure/util/timer'
|
||||
require 'vagrant-azure/util/machine_id_helper'
|
||||
require 'haikunator'
|
||||
|
||||
module VagrantPlugins
|
||||
|
@ -13,6 +14,7 @@ module VagrantPlugins
|
|||
module Action
|
||||
class RunInstance
|
||||
include Vagrant::Util::Retryable
|
||||
include VagrantPlugins::Azure::Util::MachineIdHelper
|
||||
|
||||
def initialize(app, env)
|
||||
@app = app
|
||||
|
@ -23,8 +25,10 @@ module VagrantPlugins
|
|||
# Initialize metrics if they haven't been
|
||||
env[:metrics] ||= {}
|
||||
|
||||
machine = env[:machine]
|
||||
|
||||
# Get the configs
|
||||
config = env[:machine].provider_config
|
||||
config = machine.provider_config
|
||||
endpoint = config.endpoint
|
||||
resource_group_name = config.resource_group_name
|
||||
location = config.location
|
||||
|
@ -61,7 +65,6 @@ module VagrantPlugins
|
|||
@logger.info("Time to fetch os image details: #{env[:metrics]['get_image_details']}")
|
||||
|
||||
deployment_params = {
|
||||
sshKeyData: File.read(File.expand_path('~/.ssh/id_rsa.pub')),
|
||||
dnsLabelPrefix: Haikunator.haikunate(100),
|
||||
vmSize: vm_size,
|
||||
vmName: vm_name,
|
||||
|
@ -73,11 +76,22 @@ module VagrantPlugins
|
|||
virtualNetworkName: virtual_network_name
|
||||
}
|
||||
|
||||
if get_image_os(image_details) != 'Windows'
|
||||
private_key_paths = machine.config.ssh.private_key_path
|
||||
if private_key_paths.empty?
|
||||
raise I18n.t('vagrant_azure.private_key_not_specified')
|
||||
end
|
||||
|
||||
paths_to_pub = private_key_paths.map{ |k| File.expand_path( k + '.pub') }.select{ |p| File.exists?(p) }
|
||||
raise I18n.t('vagrant_azure.public_key_path_private_key', private_key_paths.join(', ')) if paths_to_pub.empty?
|
||||
deployment_params.merge!(sshKeyData: File.read(paths_to_pub.first))
|
||||
end
|
||||
|
||||
template_params = {
|
||||
operating_system: get_image_os(image_details)
|
||||
operating_system: get_image_os(image_details)
|
||||
}
|
||||
|
||||
env[:ui].info(" -- Putting Resource Group: #{resource_group_name}")
|
||||
env[:ui].info(" -- Create or Update of Resource Group: #{resource_group_name}")
|
||||
env[:metrics]['put_resource_group'] = Util::Timer.time do
|
||||
put_resource_group(azure, resource_group_name, location)
|
||||
end
|
||||
|
@ -92,7 +106,7 @@ module VagrantPlugins
|
|||
env[:ui].info('Finished deploying')
|
||||
|
||||
# Immediately save the ID since it is created at this point.
|
||||
env[:machine].id = "#{resource_group_name}:#{vm_name}"
|
||||
env[:machine].id = serialize_machine_id(resource_group_name, vm_name, location)
|
||||
|
||||
@logger.info("Time to deploy: #{env[:metrics]['deployment_time']}")
|
||||
unless env[:interrupted]
|
||||
|
|
|
@ -2,29 +2,50 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See License in the project root for license information.
|
||||
require 'log4r'
|
||||
require 'vagrant-azure/util/machine_id_helper'
|
||||
require 'vagrant-azure/util/vm_status_translator'
|
||||
require 'vagrant/util/retryable'
|
||||
require 'vagrant-azure/util/timer'
|
||||
require 'vagrant-azure/util/vm_await'
|
||||
|
||||
# require 'vagrant/util/retryable'
|
||||
|
||||
# Bare bones basic implementation. This a work in progress in very early stages
|
||||
module VagrantPlugins
|
||||
module Azure
|
||||
module Action
|
||||
# This starts a stopped instance
|
||||
class StartInstance
|
||||
include VagrantPlugins::Azure::Util::MachineIdHelper
|
||||
include VagrantPlugins::Azure::Util::VMStatusTranslator
|
||||
include VagrantPlugins::Azure::Util::VMAwait
|
||||
|
||||
def initialize(app, env)
|
||||
@app = app
|
||||
@logger = Log4r::Logger.new('vagrant_azure:action::start_instance')
|
||||
@logger = Log4r::Logger.new('vagrant_azure::action::start_instance')
|
||||
end
|
||||
|
||||
def call(env)
|
||||
env[:machine].id = "#{env[:machine].provider_config.vm_name}@#{env[:machine].provider_config.cloud_service_name}" unless env[:machine].id
|
||||
env[:machine].id =~ /@/
|
||||
env[:metrics] ||= {}
|
||||
|
||||
VagrantPlugins::Azure::CLOUD_SERVICE_SEMAPHORE.synchronize do
|
||||
env[:ui].info "Attempting to start '#{$`}' in '#{$'}'"
|
||||
env[:azure_vm_service].start_virtual_machine($`, $')
|
||||
parsed = parse_machine_id(env[:machine].id)
|
||||
azure = env[:azure_arm_service]
|
||||
env[:ui].info(I18n.t('vagrant_azure.starting', parsed))
|
||||
azure.compute.virtual_machines.start(parsed[:group], parsed[:name])
|
||||
|
||||
# Wait for the instance to be ready first
|
||||
env[:metrics]['instance_ready_time'] = Util::Timer.time do
|
||||
|
||||
env[:ui].info(I18n.t('vagrant_azure.waiting_for_ready'))
|
||||
|
||||
task = await_true(env) do |vm|
|
||||
running?(vm.properties.instance_view.statuses)
|
||||
end
|
||||
|
||||
if task.value
|
||||
env[:ui].info(I18n.t('vagrant_azure.started', parsed))
|
||||
else
|
||||
raise I18n.t('vagrant_azure.errors.failed_starting', parsed) unless env[:interrupted]
|
||||
end
|
||||
end
|
||||
|
||||
@app.call(env)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,12 +2,18 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See License in the project root for license information.
|
||||
require 'log4r'
|
||||
require 'vagrant-azure/util/machine_id_helper'
|
||||
require 'vagrant-azure/util/vm_await'
|
||||
require 'vagrant-azure/util/vm_status_translator'
|
||||
require 'vagrant-azure/util/timer'
|
||||
|
||||
# Bare bones basic implementation. This a work in progress in very early stages
|
||||
module VagrantPlugins
|
||||
module Azure
|
||||
module Action
|
||||
class StopInstance
|
||||
include VagrantPlugins::Azure::Util::MachineIdHelper
|
||||
include VagrantPlugins::Azure::Util::VMAwait
|
||||
include VagrantPlugins::Azure::Util::VMStatusTranslator
|
||||
|
||||
def initialize(app, env)
|
||||
@app = app
|
||||
|
@ -15,23 +21,34 @@ module VagrantPlugins
|
|||
end
|
||||
|
||||
def call(env)
|
||||
if env[:machine].state.id == :StoppedDeallocated
|
||||
env[:ui].info(
|
||||
I18n.t('vagrant_azure.already_status', :status => 'stopped.')
|
||||
)
|
||||
env[:metrics] ||= {}
|
||||
|
||||
parsed = parse_machine_id(env[:machine].id)
|
||||
if env[:machine].state.id == :stopped
|
||||
env[:ui].info(I18n.t('vagrant_azure.already_status', :status => 'stopped.'))
|
||||
else
|
||||
env[:machine].id =~ /@/
|
||||
VagrantPlugins::Azure::CLOUD_SERVICE_SEMAPHORE.synchronize do
|
||||
env[:ui].info(
|
||||
I18n.t(
|
||||
'vagrant_azure.stopping',
|
||||
:vm_name => $`,
|
||||
:cloud_service_name => $'
|
||||
)
|
||||
)
|
||||
env[:azure_vm_service].shutdown_virtual_machine($`, $')
|
||||
env[:ui].info(I18n.t('vagrant_azure.stopping', parsed))
|
||||
env[:azure_arm_service].compute.virtual_machines.power_off(parsed[:group], parsed[:name])
|
||||
|
||||
# Wait for the instance to be ready first
|
||||
env[:metrics]['instance_stop_time'] = Util::Timer.time do
|
||||
|
||||
env[:ui].info(I18n.t('vagrant_azure.waiting_for_stop'))
|
||||
|
||||
task = await_true(env) do |vm|
|
||||
stopped?(vm.properties.instance_view.statuses)
|
||||
end
|
||||
|
||||
if task.value
|
||||
env[:ui].info(I18n.t('vagrant_azure.stopped', parsed))
|
||||
else
|
||||
raise I18n.t('vagrant_azure.errors.failed_starting', parsed) unless env[:interrupted]
|
||||
end
|
||||
end
|
||||
|
||||
env[:ui].info(I18n.t('vagrant_azure.stopped', parsed))
|
||||
end
|
||||
|
||||
@app.call(env)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,20 +2,32 @@
|
|||
# Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
# Licensed under the MIT License. See License in the project root for license information.
|
||||
require 'log4r'
|
||||
require 'vagrant-azure/util/machine_id_helper'
|
||||
|
||||
module VagrantPlugins
|
||||
module Azure
|
||||
module Action
|
||||
class TerminateInstance
|
||||
include VagrantPlugins::Azure::Util::MachineIdHelper
|
||||
|
||||
def initialize(app, env)
|
||||
@app = app
|
||||
@logger = Log4r::Logger.new('vagrant_azure::action::terminate_instance')
|
||||
end
|
||||
|
||||
def call(env)
|
||||
rg_name, vm_name = env[:machine].id.split(':')
|
||||
parsed = parse_machine_id(env[:machine].id)
|
||||
|
||||
begin
|
||||
env[:ui].info(I18n.t('vagrant_azure.terminating', parsed))
|
||||
env[:azure_arm_service].resources.resource_groups.delete(parsed[:group]).value!.body
|
||||
rescue MsRestAzure::AzureOperationError => ex
|
||||
unless ex.response.status == 404
|
||||
raise ex
|
||||
end
|
||||
end
|
||||
env[:ui].info(I18n.t('vagrant_azure.terminated', parsed))
|
||||
|
||||
env[:azure_arm_service].compute.virtual_machines.delete(rg_name, vm_name).value!.body
|
||||
env[:machine].id = nil
|
||||
|
||||
@app.call(env)
|
||||
|
|
|
@ -28,7 +28,7 @@ module VagrantPlugins
|
|||
# @return [String]
|
||||
attr_accessor :subscription_id
|
||||
|
||||
# (Optional) Name of the resource group to use. WARNING: the resource group will be removed upon destroy!!!
|
||||
# (Optional) Name of the resource group to use.
|
||||
#
|
||||
# @return [String]
|
||||
attr_accessor :resource_group_name
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
module VagrantPlugins
|
||||
module Azure
|
||||
module Util
|
||||
module MachineIdHelper
|
||||
def parse_machine_id(id)
|
||||
parts = id.split(':')
|
||||
{
|
||||
group: parts[0],
|
||||
name: parts[1],
|
||||
location: parts[2]
|
||||
}
|
||||
end
|
||||
|
||||
def serialize_machine_id(resource_group, vm_name, location)
|
||||
[resource_group, vm_name, location].join(':')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,36 @@
|
|||
module VagrantPlugins
|
||||
module Azure
|
||||
module Util
|
||||
module VMAwait
|
||||
|
||||
def await_true(env)
|
||||
config = env[:machine].provider_config
|
||||
parsed = parse_machine_id(env[:machine].id)
|
||||
azure = env[:azure_arm_service]
|
||||
tries = config.instance_ready_timeout / 2
|
||||
count = 0
|
||||
task = Concurrent::TimerTask.new(execution_interval: config.instance_check_interval ) do
|
||||
task.shutdown if env[:interrupted]
|
||||
|
||||
if count > tries
|
||||
task.shutdown
|
||||
false
|
||||
end
|
||||
|
||||
count += 1
|
||||
vm = azure.compute.virtual_machines.get(parsed[:group], parsed[:name], 'instanceView').value!.body
|
||||
if yield(vm)
|
||||
task.shutdown
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
task.execute
|
||||
task.wait_for_termination
|
||||
task
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,59 @@
|
|||
module VagrantPlugins
|
||||
module Azure
|
||||
module Util
|
||||
module VMStatusTranslator
|
||||
|
||||
PROVISIONING_STATES = [:provisioned, :deleting]
|
||||
POWER_STATES = [:running, :starting, :deallocating, :deallocated]
|
||||
|
||||
def vm_status_to_state(status)
|
||||
code = status.code
|
||||
case
|
||||
when code == 'ProvisioningState/succeeded'
|
||||
:provisioned
|
||||
when code == 'ProvisioningState/deleting'
|
||||
:deleting
|
||||
when code == 'PowerState/running'
|
||||
:running
|
||||
when code == 'PowerState/stopping'
|
||||
:stopping
|
||||
when code == 'PowerState/stopped'
|
||||
:stopped
|
||||
when code == 'PowerState/starting'
|
||||
:starting
|
||||
when code == 'PowerState/deallocating'
|
||||
:deallocating
|
||||
when code == 'PowerState/deallocated'
|
||||
:deallocated
|
||||
else
|
||||
:unknown
|
||||
end
|
||||
end
|
||||
|
||||
def power_state(statuses)
|
||||
vm_status_to_state(statuses.select{ |s| s.code.match(/PowerState/) }.last)
|
||||
end
|
||||
|
||||
def running?(statuses)
|
||||
statuses.any?{ |s| vm_status_to_state(s) == :running }
|
||||
end
|
||||
|
||||
def built?(statuses)
|
||||
statuses.any?{ |s| vm_status_to_state(s) == :provisioned }
|
||||
end
|
||||
|
||||
def stopped?(statuses)
|
||||
statuses.any?{ |s| vm_status_to_state(s) == :stopped }
|
||||
end
|
||||
|
||||
def stopping?(statuses)
|
||||
statuses.any?{ |s| vm_status_to_state(s) == :stopping }
|
||||
end
|
||||
|
||||
def tearing_down?(statuses)
|
||||
statuses.any?{ |s| vm_status_to_state(s) == :deleting }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -9,13 +9,25 @@ en:
|
|||
already_status: |-
|
||||
The machine is already %{status}.
|
||||
stopping: |-
|
||||
Stopping '%{vm_name}' in '%{cloud_service_name}'
|
||||
Stopping '%{name}' in '%{group}'
|
||||
terminating: |-
|
||||
Terminating '%{name}'
|
||||
terminated: |-
|
||||
Terminated '%{name}'
|
||||
rdp_not_ready: |-
|
||||
RDP not ready
|
||||
vm_started: |-
|
||||
starting: |-
|
||||
VM '%{name}' is starting
|
||||
started: |-
|
||||
VM '%{name}' has been started
|
||||
vm_stopped: |-
|
||||
stopping: |-
|
||||
VM '%{name}' is stopping
|
||||
stopped: |-
|
||||
VM '%{name}' has been stopped
|
||||
restarting: |-
|
||||
VM '%{name}' is restarting
|
||||
restarted: |-
|
||||
VM '%{name}' has been restarted
|
||||
copy_folder: |-
|
||||
Copying folder: %{hostpath} ==>
|
||||
%{guestpath}
|
||||
|
@ -37,6 +49,10 @@ en:
|
|||
Server not created. Error is: %{message}
|
||||
create_vm_failure: |-
|
||||
There was some error in creating the VM.
|
||||
failed_starting: |-
|
||||
Failed to start VM '%{name}'!!
|
||||
failed_stopping: |-
|
||||
Failed to stopping VM '%{name}'!!
|
||||
subscription_id:
|
||||
required: |-
|
||||
You must provide an Azure Subscription Id either through ENV['AZURE_SUBSCRIPTION_ID'] or via Vagrantfile.
|
||||
|
@ -78,3 +94,15 @@ en:
|
|||
Waiting for SSH to become available...
|
||||
ready: |-
|
||||
Machine is booted and ready for use!
|
||||
public_key_path_private_key: |-
|
||||
We expect the public key to be added to the Azure VM to be located at the
|
||||
same place as the config.ssh.private_key_path + '.pub'. We couldn't find
|
||||
any public keys for these private key paths:
|
||||
private_key_not_specified: |-
|
||||
Please specify a secure key to use with config.ssh.private_key_path
|
||||
(see: https://www.vagrantup.com/docs/vagrantfile/ssh_settings.html).
|
||||
If not, you publicly accessible Azure VM will be extremely insecure.
|
||||
waiting_for_ready: |-
|
||||
Waiting for instance to become "ready"...
|
||||
waiting_for_stop: |-
|
||||
Waiting for instance to become "stopped"...
|
||||
|
|
Загрузка…
Ссылка в новой задаче