merge: with merge remote

pull/9019/head
ibuler 2022-11-04 11:43:34 +08:00
commit fbf65f437a
24 changed files with 312 additions and 86 deletions

View File

@ -4,6 +4,7 @@ from rest_framework import status, viewsets
from rest_framework.response import Response
from orgs.mixins.api import OrgBulkModelViewSet
from common.const.choices import Trigger
from assets import serializers
from assets.tasks import execute_account_backup_plan
from assets.models import (
@ -38,9 +39,7 @@ class AccountBackupPlanExecutionViewSet(viewsets.ModelViewSet):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
pid = serializer.data.get('plan')
task = execute_account_backup_plan.delay(
pid=pid, trigger=AccountBackupPlanExecution.Trigger.manual
)
task = execute_account_backup_plan.delay(pid=pid, trigger=Trigger.manual)
return Response({'task': task.id}, status=status.HTTP_201_CREATED)
def filter_queryset(self, queryset):

View File

@ -82,15 +82,16 @@ class AssetAccountHandler(BaseAccountHandler):
# TODO 可以优化一下查询 在账号上做 category 的缓存 避免数据量大时连表操作
qs = Account.objects.filter(
asset__platform__category__in=categories
).annotate(category=F('asset__platform__category'))
asset__platform__type__in=categories
).annotate(category=F('asset__platform__type'))
print(qs, categories)
if not qs.exists():
return data_map
category_dict = {}
for i in AllTypes.grouped_choices_to_objs():
for j in i['children']:
category_dict[j['value']] = j['label']
category_dict[j['value']] = j['display_name']
header_fields = cls.get_header_fields(AccountSecretSerializer(qs.first()))
account_category_map = defaultdict(list)

View File

@ -12,7 +12,7 @@ from .handlers import AccountBackupHandler
logger = get_logger(__name__)
class AccountBackupExecutionManager:
class AccountBackupManager:
def __init__(self, execution):
self.execution = execution
self.date_start = timezone.now()

View File

@ -1,17 +1,28 @@
import os
import time
import random
import string
from copy import deepcopy
from openpyxl import Workbook
from collections import defaultdict
from django.utils import timezone
from django.conf import settings
from common.utils import lazyproperty, gen_key_pair
from common.utils.timezone import local_now_display
from common.utils.file import encrypt_and_compress_zip_file
from common.utils import get_logger, lazyproperty, gen_key_pair
from users.models import User
from assets.models import ChangeSecretRecord
from assets.notifications import ChangeSecretExecutionTaskMsg
from assets.serializers import ChangeSecretRecordBackUpSerializer
from assets.const import (
AutomationTypes, SecretType, SecretStrategy, SSHKeyStrategy, DEFAULT_PASSWORD_RULES
)
from ..base.manager import BasePlaybookManager
logger = get_logger(__name__)
class ChangeSecretManager(BasePlaybookManager):
def __init__(self, *args, **kwargs):
@ -125,7 +136,7 @@ class ChangeSecretManager(BasePlaybookManager):
new_secret = self.get_secret()
recorder = ChangeSecretRecord(
account=account, execution=self.execution,
asset=asset, account=account, execution=self.execution,
old_secret=account.secret, new_secret=new_secret,
)
records.append(recorder)
@ -172,4 +183,49 @@ class ChangeSecretManager(BasePlaybookManager):
recorder.save()
def on_runner_failed(self, runner, e):
pass
logger.error("Change secret error: ", e)
def run(self, *args, **kwargs):
super().run(*args, **kwargs)
recorders = self.name_recorder_mapper.values()
recorders = list(recorders)
self.send_recorder_mail(recorders)
def send_recorder_mail(self, recorders):
recipients = self.execution.recipients
if not recorders or not recipients:
return
recipients = User.objects.filter(id__in=list(recipients))
name = self.execution.snapshot['name']
path = os.path.join(os.path.dirname(settings.BASE_DIR), 'tmp')
filename = os.path.join(path, f'{name}-{local_now_display()}-{time.time()}.xlsx')
if not self.create_file(recorders, filename):
return
for user in recipients:
attachments = []
if user.secret_key:
password = user.secret_key.encode('utf8')
attachment = os.path.join(path, f'{name}-{local_now_display()}-{time.time()}.zip')
encrypt_and_compress_zip_file(attachment, password, [filename])
attachments = [attachment]
ChangeSecretExecutionTaskMsg(name, user).publish(attachments)
os.remove(filename)
@staticmethod
def create_file(recorders, filename):
serializer_cls = ChangeSecretRecordBackUpSerializer
serializer = serializer_cls(recorders, many=True)
header = [v.label for v in serializer.child.fields.values()]
rows = [list(row.values()) for row in serializer.data]
if not rows:
return False
rows.insert(0, header)
wb = Workbook(filename)
ws = wb.create_sheet('Sheet1')
for row in rows:
ws.append(row)
wb.save(filename)
return True

View File

@ -3,6 +3,7 @@ from .gather_facts.manager import GatherFactsManager
from .gather_accounts.manager import GatherAccountsManager
from .verify_account.manager import VerifyAccountManager
from .push_account.manager import PushAccountManager
from .backup_account.manager import AccountBackupManager
from ..const import AutomationTypes
@ -13,6 +14,8 @@ class ExecutionManager:
AutomationTypes.gather_accounts: GatherAccountsManager,
AutomationTypes.verify_account: VerifyAccountManager,
AutomationTypes.push_account: PushAccountManager,
# TODO 后期迁移到自动化策略中
'backup_account': AccountBackupManager,
}
def __init__(self, execution):
@ -21,4 +24,3 @@ class ExecutionManager:
def run(self, *args, **kwargs):
return self._runner.run(*args, **kwargs)

View File

@ -0,0 +1,18 @@
# Generated by Django 3.2.14 on 2022-11-03 08:44
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('assets', '0108_auto_20221027_1053'),
]
operations = [
migrations.RenameField(
model_name='accountbackupplan',
old_name='categories',
new_name='types',
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.14 on 2022-11-03 13:57
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('assets', '0109_rename_categories_to_types'),
]
operations = [
migrations.AddField(
model_name='changesecretrecord',
name='asset',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='assets.asset'),
),
]

View File

@ -111,6 +111,13 @@ class AutomationExecution(OrgModelMixin):
def manager_type(self):
return self.snapshot['type']
@property
def recipients(self):
recipients = self.snapshot.get('recipients')
if not recipients:
return []
return recipients.values()
def start(self):
from assets.automations.endpoint import ExecutionManager
manager = ExecutionManager(execution=self)

View File

@ -51,6 +51,7 @@ class ChangeSecretAutomation(BaseAutomation):
class ChangeSecretRecord(JMSBaseModel):
execution = models.ForeignKey('assets.AutomationExecution', on_delete=models.CASCADE)
asset = models.ForeignKey('assets.Asset', on_delete=models.CASCADE, null=True)
account = models.ForeignKey('assets.Account', on_delete=models.CASCADE, null=True)
old_secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('Old secret'))
new_secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('Secret'))

View File

@ -2,7 +2,6 @@
# -*- coding: utf-8 -*-
#
import uuid
from functools import reduce
from celery import current_task
from django.db import models
@ -11,9 +10,9 @@ from django.utils.translation import ugettext_lazy as _
from orgs.mixins.models import OrgModelMixin
from ops.mixin import PeriodTaskModelMixin
from common.utils import get_logger
from common.const.choices import Trigger
from common.db.encoder import ModelJSONFieldEncoder
from common.mixins.models import CommonModelMixin
from common.const.choices import Trigger
__all__ = ['AccountBackupPlan', 'AccountBackupPlanExecution']
@ -22,7 +21,7 @@ logger = get_logger(__file__)
class AccountBackupPlan(CommonModelMixin, PeriodTaskModelMixin, OrgModelMixin):
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
categories = models.JSONField(default=list)
types = models.JSONField(default=list)
recipients = models.ManyToManyField(
'users.User', related_name='recipient_escape_route_plans', blank=True,
verbose_name=_("Recipient")
@ -53,7 +52,7 @@ class AccountBackupPlan(CommonModelMixin, PeriodTaskModelMixin, OrgModelMixin):
'crontab': self.crontab,
'org_id': self.org_id,
'created_by': self.created_by,
'categories': self.categories,
'types': self.types,
'recipients': {
str(recipient.id): (str(recipient), bool(recipient.secret_key))
for recipient in self.recipients.all()
@ -100,9 +99,9 @@ class AccountBackupPlanExecution(OrgModelMixin):
verbose_name = _('Account backup execution')
@property
def categories(self):
categories = self.plan_snapshot.get('categories')
return categories
def types(self):
types = self.plan_snapshot.get('types')
return types
@property
def recipients(self):
@ -111,7 +110,11 @@ class AccountBackupPlanExecution(OrgModelMixin):
return []
return recipients.values()
@property
def manager_type(self):
return 'backup_account'
def start(self):
from ..task_handlers import ExecutionManager
from assets.automations.endpoint import ExecutionManager
manager = ExecutionManager(execution=self)
return manager.run()

View File

@ -2,10 +2,9 @@
#
import io
import os
import uuid
import sshpubkeys
from hashlib import md5
import sshpubkeys
from django.db import models
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
@ -14,11 +13,11 @@ from django.db.models import QuerySet
from common.utils import (
ssh_key_string_to_obj, ssh_key_gen, get_logger,
random_string, ssh_pubkey_gen,
random_string, ssh_pubkey_gen, lazyproperty
)
from common.db import fields
from assets.const import Connectivity
from orgs.mixins.models import JMSOrgBaseModel
from assets.const import Connectivity, SecretType
logger = get_logger(__file__)
@ -48,12 +47,6 @@ class AbsConnectivity(models.Model):
class BaseAccount(JMSOrgBaseModel):
class SecretType(models.TextChoices):
password = 'password', _('Password')
ssh_key = 'ssh_key', _('SSH key')
access_key = 'access_key', _('Access key')
token = 'token', _('Token')
name = models.CharField(max_length=128, verbose_name=_("Name"))
username = models.CharField(max_length=128, blank=True, verbose_name=_('Username'), db_index=True)
secret_type = models.CharField(
@ -65,28 +58,34 @@ class BaseAccount(JMSOrgBaseModel):
comment = models.TextField(blank=True, verbose_name=_('Comment'))
created_by = models.CharField(max_length=128, null=True, verbose_name=_('Created by'))
@property
def password(self):
return self.secret
@property
def has_secret(self):
return bool(self.secret)
@property
def private_key(self):
if self.secret_type == self.SecretType.ssh_key:
return self.secret
return None
def specific(self):
data = {}
if self.secret_type != SecretType.ssh_key:
return data
data['ssh_key_fingerprint'] = self.ssh_key_fingerprint
return data
@property
def public_key(self):
return ''
def private_key(self):
if self.secret_type == SecretType.ssh_key:
return self.secret
return None
@private_key.setter
def private_key(self, value):
self.secret = value
self.secret_type = 'private_key'
self.secret_type = SecretType.ssh_key
@lazyproperty
def public_key(self):
if self.secret_type == SecretType.ssh_key:
return ssh_pubkey_gen(private_key=self.private_key)
return None
@property
def ssh_key_fingerprint(self):
@ -94,7 +93,7 @@ class BaseAccount(JMSOrgBaseModel):
public_key = self.public_key
elif self.private_key:
try:
public_key = ssh_pubkey_gen(private_key=self.private_key, password=self.password)
public_key = ssh_pubkey_gen(private_key=self.private_key)
except IOError as e:
return str(e)
else:
@ -107,14 +106,14 @@ class BaseAccount(JMSOrgBaseModel):
@property
def private_key_obj(self):
if self.private_key:
key_obj = ssh_key_string_to_obj(self.private_key, password=self.password)
key_obj = ssh_key_string_to_obj(self.private_key)
return key_obj
else:
return None
@property
def private_key_path(self):
if not self.secret_type != 'ssh_key' or not self.secret:
if not self.secret_type != SecretType.ssh_key or not self.secret:
return None
project_dir = settings.PROJECT_DIR
tmp_dir = os.path.join(project_dir, 'tmp')
@ -156,7 +155,6 @@ class BaseAccount(JMSOrgBaseModel):
return {
'name': self.name,
'username': self.username,
'password': self.password,
'public_key': self.public_key,
}

View File

@ -15,11 +15,35 @@ class AccountBackupExecutionTaskMsg(object):
def message(self):
name = self.name
if self.user.secret_key:
return _('{} - The account backup passage task has been completed. See the attachment for details').format(name)
return _('{} - The account backup passage task has been completed. See the attachment for details').format(
name)
return _("{} - The account backup passage task has been completed: the encryption password has not been set - "
"please go to personal information -> file encryption password to set the encryption password").format(name)
"please go to personal information -> file encryption password to set the encryption password").format(
name)
def publish(self, attachment_list=None):
send_mail_attachment_async.delay(
send_mail_attachment_async(
self.subject, self.message, [self.user.email], attachment_list
)
class ChangeSecretExecutionTaskMsg(object):
subject = _('Notification of implementation result of encryption change plan')
def __init__(self, name: str, user: User):
self.name = name
self.user = user
@property
def message(self):
name = self.name
if self.user.secret_key:
return _('{} - The encryption change task has been completed. See the attachment for details').format(name)
return _("{} - The encryption change task has been completed: the encryption password has not been set - "
"please go to personal information -> file encryption password to set the encryption password").format(
name)
def publish(self, attachments=None):
send_mail_attachment_async(
self.subject, self.message, [self.user.email], attachments
)

View File

@ -11,3 +11,4 @@ from .account import *
from assets.serializers.account.backup import *
from .platform import *
from .cagegory import *
from .automation import *

View File

@ -3,6 +3,7 @@ from rest_framework import serializers
from common.drf.serializers import SecretReadableMixin
from common.drf.fields import ObjectRelatedField
from assets.tasks import push_accounts_to_assets
from assets.models import Account, AccountTemplate, Asset
from .base import BaseAccountSerializer
@ -47,8 +48,7 @@ class AccountSerializerCreateMixin(serializers.ModelSerializer):
def create(self, validated_data):
instance = super().create(validated_data)
if self.push_now:
# Todo: push it
print("Start push account to asset")
push_accounts_to_assets.delay([instance.id], [instance.asset_id])
return instance

View File

@ -21,9 +21,13 @@ class BaseAccountSerializer(BulkOrgResourceModelSerializer):
class Meta:
model = BaseAccount
fields_mini = ['id', 'name', 'username']
fields_small = fields_mini + ['privileged', 'secret_type', 'secret', 'has_secret']
fields_small = fields_mini + ['privileged', 'secret_type', 'secret', 'has_secret', 'specific']
fields_other = ['created_by', 'date_created', 'date_updated', 'comment']
fields = fields_small + fields_other
read_only_fields = [
'has_secret', 'specific',
'date_verified', 'created_by', 'date_created',
]
extra_kwargs = {
'secret': {'write_only': True},
'passphrase': {'write_only': True},

View File

@ -1,6 +1,3 @@
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
from assets.models import AccountTemplate
from .base import BaseAccountSerializer
@ -9,15 +6,14 @@ class AccountTemplateSerializer(BaseAccountSerializer):
class Meta(BaseAccountSerializer.Meta):
model = AccountTemplate
@classmethod
def validate_required(cls, attrs):
# Todo: why ?
required_field_dict = {}
error = _('This field is required.')
for k, v in cls().fields.items():
if v.required and k not in attrs:
required_field_dict[k] = error
if not required_field_dict:
return
raise serializers.ValidationError(required_field_dict)
# @classmethod
# def validate_required(cls, attrs):
# # TODO 选择模版后检查一些必填项
# required_field_dict = {}
# error = _('This field is required.')
# for k, v in cls().fields.items():
# if v.required and k not in attrs:
# required_field_dict[k] = error
# if not required_field_dict:
# return
# raise serializers.ValidationError(required_field_dict)

View File

@ -0,0 +1,35 @@
from django.utils.translation import ugettext as _
from rest_framework import serializers
from common.utils import get_logger
from assets.models import ChangeSecretRecord
logger = get_logger(__file__)
class ChangeSecretRecordBackUpSerializer(serializers.ModelSerializer):
asset = serializers.SerializerMethodField(label=_('Asset'))
account = serializers.SerializerMethodField(label=_('Account'))
is_success = serializers.SerializerMethodField(label=_('Is success'))
class Meta:
model = ChangeSecretRecord
fields = [
'id', 'asset', 'account', 'old_secret', 'new_secret',
'status', 'error', 'is_success'
]
@staticmethod
def get_asset(instance):
return str(instance.asset)
@staticmethod
def get_account(instance):
return str(instance.account)
@staticmethod
def get_is_success(obj):
if obj.status == 'success':
return _("Success")
return _("Failed")

View File

@ -11,8 +11,9 @@ __all__ = [
@org_aware_func("assets")
def push_accounts_to_assets_util(accounts, assets, task_name):
def push_accounts_to_assets_util(accounts, assets):
from assets.models import PushAccountAutomation
task_name = gettext_noop("Push accounts to assets")
task_name = PushAccountAutomation.generate_unique_name(task_name)
account_usernames = list(accounts.values_list('username', flat=True))
@ -33,5 +34,4 @@ def push_accounts_to_assets(account_ids, asset_ids):
assets = Asset.objects.get(id=asset_ids)
accounts = Account.objects.get(id=account_ids)
task_name = gettext_noop("Push accounts to assets")
return push_accounts_to_assets_util(accounts, assets, task_name)
return push_accounts_to_assets_util(accounts, assets)

View File

@ -4,3 +4,4 @@ from .common import *
from .nodes import *
from .assets import *
from .nodes_with_assets import *
from .accounts import *

View File

@ -0,0 +1,24 @@
from rest_framework import generics
from assets.serializers import AccountSerializer
from perms.utils.account import PermAccountUtil
from .mixin import RoleAdminMixin, RoleUserMixin
__all__ = ['UserAllGrantedAccountsApi', 'MyAllGrantedAccountsApi']
class UserAllGrantedAccountsApi(RoleAdminMixin, generics.ListAPIView):
""" 授权给用户的所有账号列表 """
serializer_class = AccountSerializer
filterset_fields = ("name", "username", "privileged", "version")
search_fields = filterset_fields
def get_queryset(self):
util = PermAccountUtil()
accounts = util.get_perm_accounts_for_user(self.user)
return accounts
class MyAllGrantedAccountsApi(RoleUserMixin, UserAllGrantedAccountsApi):
""" 授权给我的所有账号列表 """
pass

View File

@ -58,9 +58,12 @@ user_permission_urlpatterns = [
# 收藏的资产
path('<uuid:pk>/nodes/favorite/assets/', api.UserFavoriteGrantedAssetsApi.as_view(), name='user-ungrouped-assets'),
path('nodes/favorite/assets/', api.MyFavoriteGrantedAssetsApi.as_view(), name='my-ungrouped-assets'),
# v3 中上面的 API 基本不用动
# 获取所有和资产-用户关联的账号列表
# 获取授权给用户的所有账号
path('<uuid:pk>/accounts/', api.UserAllGrantedAccountsApi.as_view(), name='user-accounts'),
path('accounts/', api.MyAllGrantedAccountsApi.as_view(), name='my-accounts'),
# 获取授权给用户某个资产的所有账号
path('<uuid:pk>/assets/<uuid:asset_id>/accounts/', api.UserGrantedAssetAccountsApi.as_view(), name='user-asset-accounts'),
path('assets/<uuid:asset_id>/accounts/', api.MyGrantedAssetAccountsApi.as_view(), name='my-asset-accounts'),
# 用户登录资产的特殊账号, @INPUT, @USER 等

View File

@ -27,13 +27,19 @@ class DeployAppletHostManager:
def generate_playbook(self):
playbook_src = os.path.join(CURRENT_DIR, 'playbook.yml')
base_site_url = settings.BASE_SITE_URL
bootstrap_token = settings.BOOTSTRAP_TOKEN
host_id = str(self.deployment.host.id)
if not base_site_url:
base_site_url = "localhost:8080"
with open(playbook_src) as f:
plays = yaml.safe_load(f)
for play in plays:
play['vars'].update(self.deployment.host.deploy_options)
play['vars']['DownloadHost'] = settings.BASE_URL + '/download/'
play['vars']['CORE_HOST'] = settings.BASE_URL
play['vars']['BOOTSTRAP_TOKEN'] = settings.BOOSTRAP_TOKEN
play['vars']['DownloadHost'] = base_site_url + '/download/'
play['vars']['CORE_HOST'] = base_site_url
play['vars']['BOOTSTRAP_TOKEN'] = bootstrap_token
play['vars']['HOST_ID'] = host_id
play['vars']['HOST_NAME'] = self.deployment.host.name
playbook_dir = os.path.join(self.run_dir, 'playbook')
@ -70,6 +76,3 @@ class DeployAppletHostManager:
self.deployment.date_finished = timezone.now()
with safe_db_connection():
self.deployment.save()

View File

@ -5,6 +5,7 @@
DownloadHost: https://demo.jumpserver.org/download
Initial: 0
HOST_NAME: test
HOST_ID: 00000000-0000-0000-0000-000000000000
CORE_HOST: https://demo.jumpserver.org
BOOTSTRAP_TOKEN: PleaseChangeMe
RDS_Licensing: true
@ -13,6 +14,7 @@
RDS_fSingleSessionPerUser: 1
RDS_MaxDisconnectionTime: 60000
RDS_RemoteAppLogoffTimeLimit: 0
TinkerInstaller: JumpServer-Remoteapp_v0.0.1.exe
tasks:
- name: Install RDS-Licensing (RDS)
@ -29,16 +31,26 @@
include_management_tools: yes
register: rds_install
- name: Download Jmservisor (jumpserver)
- name: Download JumpServer Remoteapp installer (jumpserver)
ansible.windows.win_get_url:
url: "{{ DownloadHost }}/Jmservisor.msi"
dest: "{{ ansible_env.TEMP }}\\Jmservisor.msi"
url: "{{ DownloadHost }}/{{ TinkerInstaller }}"
dest: "{{ ansible_env.TEMP }}\\{{ TinkerInstaller }}"
- name: Install the Jmservisor (jumpserver)
- name: Install JumpServer Remoteapp agent (jumpserver)
ansible.windows.win_package:
path: "{{ ansible_env.TEMP }}\\Jmservisor.msi"
path: "{{ ansible_env.TEMP }}\\{{ TinkerInstaller }}"
arguments:
- /VERYSILENT
- /SUPPRESSMSGBOXES
- /NORESTART
state: present
- name: Set remote-server on the global system path (remote-server)
ansible.windows.win_path:
elements:
- '%USERPROFILE%\AppData\Local\Programs\JumpServer-Remoteapp\'
scope: user
- name: Download python-3.10.8
ansible.windows.win_get_url:
url: "{{ DownloadHost }}/python-3.10.8-amd64.exe"
@ -116,12 +128,12 @@
- name: Download chromedriver (chrome)
ansible.windows.win_get_url:
url: "{{ DownloadHost }}/chromedriver_win32.106.zip"
dest: "{{ ansible_env.TEMP }}\\chromedriver_win32.106.zip"
url: "{{ DownloadHost }}/chromedriver_win32.107.zip"
dest: "{{ ansible_env.TEMP }}\\chromedriver_win32.107.zip"
- name: Unzip chromedriver (chrome)
community.windows.win_unzip:
src: "{{ ansible_env.TEMP }}\\chromedriver_win32.106.zip"
src: "{{ ansible_env.TEMP }}\\chromedriver_win32.107.zip"
dest: C:\Program Files\JumpServer\drivers
- name: Set chromedriver on the global system path (chrome)
@ -142,8 +154,27 @@
- /quiet
- name: Generate component config
ansible.windows.win_shell: >
echo "Todo: Set config"
ansible.windows.win_shell:
"remoteapp-server config --hostname {{ HOST_NAME }} --core_host {{ CORE_HOST }}
--token {{ BOOTSTRAP_TOKEN }} --host_id {{ HOST_ID }}"
- name: Install remoteapp-server service
ansible.windows.win_shell:
"remoteapp-server service install"
- name: Start remoteapp-server service
ansible.windows.win_shell:
"remoteapp-server service start"
- name: Wait Tinker api health
ansible.windows.win_uri:
url: http://localhost:6068/api/health/
status_code: 200
method: GET
register: _result
until: _result.status_code == 200
retries: 30
delay: 5
- name: Sync all remote applets
ansible.windows.win_shell: >