Merge branch 'provs_k3s_to_ddd' into 'ddd-intro'

refactor provs-k3s-build to DDD

See merge request domaindrivenarchitecture/dda-devops-build!13
This commit is contained in:
Michael Jerger 2023-05-22 11:44:35 +00:00
commit 3c6e0cfc12
19 changed files with 270 additions and 195 deletions

View file

@ -28,7 +28,7 @@ use_plugin("python.distutils")
default_task = "publish"
name = "ddadevops"
version = "4.0.0-dev45"
version = "4.0.0-dev47"
summary = "tools to support builds combining gopass, terraform, dda-pallet, aws & hetzner-cloud"
description = __doc__
authors = [Author("meissa GmbH", "buero@meissa-gmbh.de")]

View file

@ -72,8 +72,8 @@ classDiagram
tag_and_push_release()
}
class ProvsK3sMixin {
// ProvsK3sMixin -> ProvsK3sBuild
class ProvsK3sBuild {
// ProvsK3sBuild -> ProvsK3sBuild
def update_runtime_config(fqdn, ipv4, ipv6=None)
write_provs_config()
provs_apply(dry_run=False)
@ -95,7 +95,7 @@ classDiagram
DevopsTerraformBuild <|-- DigitaloceanTerraformBuild
DevopsTerraformBuild <|--ExoscaleMixin
DevopsTerraformBuild <|--HetznerMixin
DevopsBuild <|-- ProvsK3sMixin
DevopsBuild <|-- ProvsK3sBuild
DigitaloceanTerraformBuild <|-- DigitaloceanBackendPropertiesMixin
AwsBackendPropertiesMixin <|-- AwsMfaMixin

View file

@ -25,6 +25,16 @@ classDiagram
c4k_executabel_name
c4k_mixin_config
c4k_mixin_auth
c4k_grafana_cloud_user
c4k_grafana_cloud_password
}
class ProvsK3s {
k3s_provision_user
k3s_letsencrypt_email
k3s_letsencrypt_endpoint
k3s_enable_echo
k3s_app_filename_to_provision
}
class DnsRecord {
@ -71,11 +81,13 @@ classDiagram
Devops *-- "0..1" Image: spcialized_builds
Devops *-- "0..1" C4k: spcialized_builds
Devops *-- "0..1" ProvsK3s: spcialized_builds
Devops *-- "0..1" Release: mixins
Release o-- "0..1" BuildFile: primary_build_file
Release o-- "0..n" BuildFile: secondary_build_files
BuildFile *-- "1" Version
C4k *-- DnsRecord
C4k *-- DnsRecord: dns_record
ProvsK3s *-- DnsRecord: provision_dns
Credentials *-- "0..n" CredentialMapping: mappings
```

View file

@ -5,14 +5,14 @@ terraform, dda-pallet, aws & hetzner-cloud.
"""
from .python_util import execute
from .provs_k3s_mixin import ProvsK3sMixin, add_provs_k3s_mixin_config
from .provs_k3s_build import ProvsK3sBuild
from .aws_mfa_mixin import AwsMfaMixin, add_aws_mfa_mixin_config
from .aws_backend_properties_mixin import AwsBackendPropertiesMixin, add_aws_backend_properties_mixin_config
from .c4k_build import C4kBuild, add_c4k_mixin_config
from .c4k_build import C4kBuild
from .digitalocean_backend_properties_mixin import DigitaloceanBackendPropertiesMixin, add_digitalocean_backend_properties_mixin_config
from .digitalocean_terraform_build import DigitaloceanTerraformBuild, create_digitalocean_terraform_build_config
from .hetzner_mixin import HetznerMixin, add_hetzner_mixin_config
from .devops_image_build import DevopsImageBuild, create_devops_docker_build_config
from .devops_image_build import DevopsImageBuild
from .devops_terraform_build import DevopsTerraformBuild, create_devops_terraform_build_config
from .devops_build import DevopsBuild, create_devops_build_config, get_devops_build
from .credential import gopass_password_from_path, gopass_field_from_path

View file

@ -1,33 +1,33 @@
from dda_python_terraform import Terraform
from .devops_terraform_build import DevopsTerraformBuild
def add_aws_backend_properties_mixin_config(config, account_name):
config.update({'AwsBackendPropertiesMixin':
{'account_name': account_name}})
config.update({"AwsBackendPropertiesMixin": {"account_name": account_name}})
return config
class AwsBackendPropertiesMixin(DevopsTerraformBuild):
class AwsBackendPropertiesMixin(DevopsTerraformBuild):
def __init__(self, project, config):
super().__init__(project, config)
aws_mixin_config = config['AwsBackendPropertiesMixin']
self.account_name = aws_mixin_config['account_name']
self.backend_config = "backend." + self.account_name + "." + self.stage + ".properties"
aws_mixin_config = config["AwsBackendPropertiesMixin"]
self.account_name = aws_mixin_config["account_name"]
self.backend_config = (
"backend." + self.account_name + "." + self.stage + ".properties"
)
self.additional_tfvar_files.append(self.backend_config)
def project_vars(self):
ret = super().project_vars()
ret.update({'account_name': self.account_name})
ret.update({"account_name": self.account_name})
return ret
def copy_build_resources_from_package(self):
super().copy_build_resources_from_package()
self.copy_build_resource_file_from_package('provider_registry.tf')
self.copy_build_resource_file_from_package('aws_provider.tf')
self.copy_build_resource_file_from_package(
'aws_backend_properties_vars.tf')
self.copy_build_resource_file_from_package(
'aws_backend_with_properties.tf')
self.copy_build_resource_file_from_package("provider_registry.tf")
self.copy_build_resource_file_from_package("aws_provider.tf")
self.copy_build_resource_file_from_package("aws_backend_properties_vars.tf")
self.copy_build_resource_file_from_package("aws_backend_with_properties.tf")
def copy_local_state(self):
pass
@ -36,14 +36,17 @@ class AwsBackendPropertiesMixin(DevopsTerraformBuild):
pass
def init_client(self):
terraform = Terraform(working_dir=self.build_path(), terraform_semantic_version=self.terraform_semantic_version)
terraform = Terraform(
working_dir=self.build_path(),
terraform_semantic_version=self.terraform_semantic_version,
)
terraform.init(backend_config=self.backend_config)
self.print_terraform_command(terraform)
if self.use_workspace:
try:
terraform.workspace('select', self.stage)
terraform.workspace("select", self.stage)
self.print_terraform_command(terraform)
except:
terraform.workspace('new', self.stage)
terraform.workspace("new", self.stage)
self.print_terraform_command(terraform)
return terraform

View file

@ -43,6 +43,7 @@ def add_c4k_mixin_config(
)
return config
class C4kBuild(DevopsBuild):
def __init__(self, project, config):
super().__init__(project, config)

View file

@ -44,13 +44,13 @@ def create_devops_terraform_build_config(stage,
class DevopsTerraformBuild(DevopsBuild):
def __init__(self, project, config):
inp = {}
inp = config.copy()
inp["name"]=project.name
inp["module"]=config.get("module")
inp["stage"]=config.get("stage")
inp["project_root_path"]=config.get("project_root_path")
inp["build_types"]=[]
inp["mixin_types"]=[]
inp["build_types"]=config.get("build_types", [])
inp["mixin_types"]=config.get("mixin_types", [])
super().__init__(project, inp)
project.build_depends_on('dda-python-terraform')
self.additional_vars = config['additional_vars']

View file

@ -1,55 +1,55 @@
from dda_python_terraform import Terraform
from .digitalocean_terraform_build import DigitaloceanTerraformBuild
def add_digitalocean_backend_properties_mixin_config(config,
account_name,
endpoint,
bucket,
key,
region='eu-central-1'):
config.update({'DigitaloceanBackendPropertiesMixin':
{'account_name': account_name,
'endpoint': endpoint,
'bucket': bucket,
'key': key,
'region': region}})
def add_digitalocean_backend_properties_mixin_config(
config, account_name, endpoint, bucket, key, region="eu-central-1"
):
config.update(
{
"DigitaloceanBackendPropertiesMixin": {
"account_name": account_name,
"endpoint": endpoint,
"bucket": bucket,
"key": key,
"region": region,
}
}
)
return config
class DigitaloceanBackendPropertiesMixin(DigitaloceanTerraformBuild):
def __init__(self, project, config):
super().__init__(project, config)
do_mixin_config = config['DigitaloceanBackendPropertiesMixin']
self.account_name = do_mixin_config['account_name']
self.endpoint = do_mixin_config['endpoint']
self.bucket = do_mixin_config['bucket']
self.key = do_mixin_config['account_name'] + \
'/' + do_mixin_config['key']
self.region = do_mixin_config['region']
do_mixin_config = config["DigitaloceanBackendPropertiesMixin"]
self.account_name = do_mixin_config["account_name"]
self.endpoint = do_mixin_config["endpoint"]
self.bucket = do_mixin_config["bucket"]
self.key = do_mixin_config["account_name"] + "/" + do_mixin_config["key"]
self.region = do_mixin_config["region"]
self.backend_config = {
'access_key': self.do_spaces_access_id,
'secret_key': self.do_spaces_secret_key,
'endpoint': self.endpoint,
'bucket': self.bucket,
'key': self.key,
'region': self.region}
"access_key": self.do_spaces_access_id,
"secret_key": self.do_spaces_secret_key,
"endpoint": self.endpoint,
"bucket": self.bucket,
"key": self.key,
"region": self.region,
}
def project_vars(self):
ret = super().project_vars()
ret.update({'account_name': self.account_name})
ret.update({'endpoint': self.endpoint})
ret.update({'bucket': self.bucket})
ret.update({'key': self.key})
ret.update({'region': self.region})
ret.update({"account_name": self.account_name})
ret.update({"endpoint": self.endpoint})
ret.update({"bucket": self.bucket})
ret.update({"key": self.key})
ret.update({"region": self.region})
return ret
def copy_build_resources_from_package(self):
super().copy_build_resources_from_package()
self.copy_build_resource_file_from_package(
'do_backend_properties_vars.tf')
self.copy_build_resource_file_from_package(
'do_backend_with_properties.tf')
self.copy_build_resource_file_from_package("do_backend_properties_vars.tf")
self.copy_build_resource_file_from_package("do_backend_with_properties.tf")
def copy_local_state(self):
pass
@ -58,15 +58,17 @@ class DigitaloceanBackendPropertiesMixin(DigitaloceanTerraformBuild):
pass
def init_client(self):
terraform = Terraform(working_dir=self.build_path(),
terraform_semantic_version=self.terraform_semantic_version)
terraform = Terraform(
working_dir=self.build_path(),
terraform_semantic_version=self.terraform_semantic_version,
)
terraform.init(backend_config=self.backend_config)
self.print_terraform_command(terraform)
if self.use_workspace:
try:
terraform.workspace('select', self.stage)
terraform.workspace("select", self.stage)
self.print_terraform_command(terraform)
except:
terraform.workspace('new', self.stage)
terraform.workspace("new", self.stage)
self.print_terraform_command(terraform)
return terraform

View file

@ -2,6 +2,7 @@ from .common import Validateable, DnsRecord, Devops, BuildType, MixinType, Relea
from .devops_factory import DevopsFactory
from .image import Image
from .c4k import C4k
from .provs_k3s import K3s
from .release import Release
from .credentials import Credentials, CredentialMapping, GopassType
from .version import Version

View file

@ -21,9 +21,9 @@ class C4k(Validateable):
self.c4k_grafana_cloud_password = inp.get('c4k_grafana_cloud_password')
self.dns_record: Optional[DnsRecord] = None
# TODO: these functions should be located at TerraformBuild later on.
def update_runtime_config(self, dns_record: DnsRecord):
self.dns_record = dns_record
self.throw_if_invalid()
def validate(self) -> List[str]:
result = []

View file

@ -9,6 +9,7 @@ def filter_none(list_to_filter):
class BuildType(Enum):
IMAGE = 0
C4K = 1
K3S = 2
class MixinType(Enum):

View file

@ -2,6 +2,7 @@ from typing import List, Optional, Dict
from .common import Validateable, Devops, BuildType, MixinType
from .image import Image
from .c4k import C4k
from .provs_k3s import K3s
from .release import Release
from .version import Version
@ -19,6 +20,8 @@ class DevopsFactory:
specialized_builds[BuildType.IMAGE] = Image(inp)
if BuildType.C4K in build_types:
specialized_builds[BuildType.C4K] = C4k(inp)
if BuildType.K3S in build_types:
specialized_builds[BuildType.K3S] = K3s(inp)
mixins: Dict[MixinType, Validateable] = {}
if MixinType.RELEASE in mixin_types:

View file

@ -0,0 +1,93 @@
from typing import List, Optional
from string import Template
from .common import (
Validateable,
DnsRecord,
Devops,
)
CONFIG_BASE = """
fqdn: $fqdn
"""
CONFIG_IPV4 = """node:
ipv4: $ipv4
"""
CONFIG_IPV6 = """ ipv6: $ipv6
"""
CONFIG_CERTMANAGER = """certmanager:
email: $letsencrypt_email
letsencryptEndpoint: $letsencrypt_endpoint
"""
CONFIG_ECHO = """echo: $echo
"""
class K3s(Validateable):
def __init__(self, inp: dict):
self.k3s_provision_user = inp.get("k3s_provision_user")
self.k3s_letsencrypt_email = inp.get("k3s_letsencrypt_email")
self.k3s_letsencrypt_endpoint = inp.get("k3s_letsencrypt_endpoint")
self.k3s_app_filename_to_provision = inp.get("k3s_app_filename_to_provision")
self.k3s_enable_echo = inp.get("k3s_enable_echo", "false")
self.k3s_provs_template = inp.get("k3s_provs_template", None)
self.provision_dns: Optional[DnsRecord] = None
def validate(self) -> List[str]:
result = []
result += self.__validate_is_not_empty__("k3s_provision_user")
result += self.__validate_is_not_empty__("k3s_letsencrypt_email")
result += self.__validate_is_not_empty__("k3s_letsencrypt_endpoint")
result += self.__validate_is_not_empty__("k3s_app_filename_to_provision")
if self.provision_dns:
result += self.provision_dns.validate()
return result
def update_runtime_config(self, dns_record: DnsRecord):
self.provision_dns = dns_record
self.throw_if_invalid()
def provs_config(self) -> str:
if not self.provision_dns:
raise ValueError("provision_dns was not set.")
substitutes = {
"fqdn": self.provision_dns.fqdn,
}
if self.provision_dns.ipv4 is not None:
substitutes["ipv4"] = self.provision_dns.ipv4
if self.provision_dns.ipv6 is not None:
substitutes["ipv6"] = self.provision_dns.ipv6
if self.k3s_letsencrypt_email is not None:
substitutes["letsencrypt_email"] = self.k3s_letsencrypt_email
if self.k3s_letsencrypt_endpoint is not None:
substitutes["letsencrypt_endpoint"] = self.k3s_letsencrypt_endpoint
if self.k3s_enable_echo is not None:
substitutes["echo"] = self.k3s_enable_echo
return self.__config_template__().substitute(substitutes)
def command(self, devops: Devops):
if not self.provision_dns:
raise ValueError("provision_dns was not set.")
cmd = [
"provs-server.jar",
"k3s",
f"{self.k3s_provision_user}@{self.provision_dns.fqdn}",
"-c",
f"{devops.build_path()}/out_k3sServerConfig.yaml",
"-a",
f"{devops.build_path()}/{self.k3s_app_filename_to_provision}",
]
return " ".join(cmd)
def __config_template__(self) -> Template:
template_text = self.k3s_provs_template
if template_text is None:
template_text = CONFIG_BASE
if self.k3s_letsencrypt_endpoint is not None:
template_text += CONFIG_CERTMANAGER
if self.k3s_enable_echo is not None:
template_text += CONFIG_ECHO
if self.provision_dns.ipv4 is not None:
template_text += CONFIG_IPV4
if self.provision_dns.ipv6 is not None:
template_text += CONFIG_IPV6
return Template(template_text)

View file

@ -98,12 +98,15 @@ class ExecutionApi:
output = output.rstrip()
return output
def execute_live(self, command):
process = Popen(command, stdout=PIPE)
for line in iter(process.stdout.readline, b""):
print(line.decode("utf-8"), end="")
process.stdout.close()
process.wait()
def execute_live(self, command, dry_run=False):
if dry_run:
print(command)
else:
process = Popen(command, stdout=PIPE)
for line in iter(process.stdout.readline, b""):
print(line.decode("utf-8"), end="")
process.stdout.close()
process.wait()
class EnvironmentApi:

View file

@ -0,0 +1,37 @@
from .domain import DnsRecord, BuildType
from .infrastructure import ExecutionApi
from .devops_build import DevopsBuild
class ProvsK3sBuild(DevopsBuild):
def __init__(self, project, config):
inp = config.copy()
inp["name"] = project.name
inp["module"] = config.get("module")
inp["stage"] = config.get("stage")
inp["project_root_path"] = config.get("project_root_path")
inp["build_types"] = config.get("build_types", [])
inp["mixin_types"] = config.get("mixin_types", [])
super().__init__(project, inp)
self.execution_api = ExecutionApi()
devops = self.devops_repo.get_devops(self.project)
if BuildType.K3S not in devops.specialized_builds:
raise ValueError("K3SBuild requires BuildType.K3S")
def update_runtime_config(self, dns_record: DnsRecord):
devops = self.devops_repo.get_devops(self.project)
devops.specialized_builds[BuildType.K3S].update_runtime_config(dns_record)
self.devops_repo.set_devops(self.project, devops)
def write_provs_config(self):
devops = self.devops_repo.get_devops(self.project)
k3s = devops.specialized_builds[BuildType.K3S]
with open(
self.build_path() + "/out_k3sServerConfig.yaml", "w", encoding="utf-8"
) as output_file:
output_file.write(k3s.provs_config())
def provs_apply(self, dry_run=False):
devops = self.devops_repo.get_devops(self.project)
k3s = devops.specialized_builds[BuildType.K3S]
self.execution_api.execute_live(k3s.command(devops), dry_run=dry_run)

View file

@ -1,119 +0,0 @@
from string import Template
import deprecation
from .python_util import execute_live
from .devops_build import DevopsBuild
CONFIG_BASE = """
fqdn: $fqdn
"""
CONFIG_IPV4 = """node:
ipv4: $ipv4
"""
CONFIG_IPV6 = """ ipv6: $ipv6
"""
CONFIG_CERTMANAGER = """certmanager:
email: $letsencrypt_email
letsencryptEndpoint: $letsencrypt_endpoint
"""
CONFIG_ECHO = """echo: $echo
"""
def add_provs_k3s_mixin_config(config,
provision_user='root',
echo=None,
k3s_config_template=None,
letsencrypt_email=None,
letsencrypt_endpoint=None,
fqdn=None,
ipv4=None,
ipv6=None,
app_filename_to_provision=None):
template_text = k3s_config_template
if template_text is None:
template_text = CONFIG_BASE
if letsencrypt_endpoint is not None:
template_text += CONFIG_CERTMANAGER
if echo is not None:
template_text += CONFIG_ECHO
if ipv4 is not None:
template_text += CONFIG_IPV4
if ipv6 is not None:
template_text += CONFIG_IPV6
config.update({'ProvsK3sMixin':
{'fqdn': fqdn,
'provision_user': provision_user,
'ipv4': ipv4,
'ipv6': ipv6,
'letsencrypt_email': letsencrypt_email,
'letsencrypt_endpoint': letsencrypt_endpoint,
'echo': echo,
'k3s_config_template': template_text,
'app_filename_to_provision': app_filename_to_provision}})
return config
class ProvsK3sMixin(DevopsBuild):
def __init__(self, project, config):
super().__init__(project, config)
provs_k3s_mixin_config = config['ProvsK3sMixin']
self.fqdn = provs_k3s_mixin_config['fqdn']
self.put('fqdn', self.fqdn)
self.provision_user = provs_k3s_mixin_config['provision_user']
self.put('provision_user', self.provision_user)
self.ipv4 = provs_k3s_mixin_config['ipv4']
self.put('ipv4', self.ipv4)
self.ipv6 = provs_k3s_mixin_config['ipv6']
self.put('ipv6', self.ipv6)
self.letsencrypt_email = provs_k3s_mixin_config['letsencrypt_email']
self.put('letsencrypt_email', self.letsencrypt_email)
self.letsencrypt_endpoint = provs_k3s_mixin_config['letsencrypt_endpoint']
self.put('letsencrypt_endpoint', self.letsencrypt_endpoint)
self.echo = provs_k3s_mixin_config['echo']
self.put('echo', self.echo)
self.k3s_config_template_text = provs_k3s_mixin_config['k3s_config_template']
self.k3s_config_template = Template(
provs_k3s_mixin_config['k3s_config_template'])
self.put('k3s_config_template', self.k3s_config_template)
self.app_filename_to_provision = provs_k3s_mixin_config['app_filename_to_provision']
self.put('app_filename_to_provision', self.app_filename_to_provision)
def update_runtime_config(self, fqdn, ipv4, ipv6=None):
self.fqdn = fqdn
self.put('fqdn', fqdn)
self.ipv4 = ipv4
self.put('ipv4', ipv4)
self.ipv6 = ipv6
self.put('ipv6', ipv6)
template_text = self.k3s_config_template_text
if ipv4 is not None:
template_text += CONFIG_IPV4
if ipv6 is not None:
template_text += CONFIG_IPV6
self.k3s_config_template_text = template_text
self.put('k3s_config_template_text', template_text)
template = Template(template_text)
self.k3s_config_template = template
self.put('k3s_config_template', template)
def write_provs_config(self):
substitutes = self.get_keys(['fqdn', 'ipv4', 'ipv6', 'letsencrypt_email',
'letsencrypt_endpoint', 'echo'])
with open(self.build_path() + '/out_k3sServerConfig.yaml', "w", encoding="utf-8") as output_file:
output_file.write(self.k3s_config_template.substitute(substitutes))
@deprecation.deprecated(deprecated_in="3.1")
def provs_server(self, dry_run=False):
self.provs_apply(dry_run)
def provs_apply(self, dry_run=False):
cmd = ['provs-server.jar', 'k3s', self.provision_user + '@' + self.fqdn, '-c',
self.build_path() + '/out_k3sServerConfig.yaml',
'-a', self.build_path() + '/' + self.app_filename_to_provision]
if dry_run:
print(" ".join(cmd))
else:
execute_live(cmd)

View file

@ -9,7 +9,7 @@ def devops_config(overrides: dict) -> dict:
"stage": "test",
"project_root_path": "root_path",
"build_dir_name": "target",
"build_types": ["IMAGE", "C4K"],
"build_types": ["IMAGE", "C4K", "K3S"],
"mixin_types": ["RELEASE"],
"image_dockerhub_user": "dockerhub_user",
"image_dockerhub_password": "dockerhub_password",
@ -19,6 +19,11 @@ def devops_config(overrides: dict) -> dict:
"c4k_grafana_cloud_password": "password",
"c4k_grafana_cloud_url": "https://prometheus-prod-01-eu-west-0.grafana.net/api/prom/push",
"c4k_auth": {},
"k3s_provision_user": "k3s_provision_user",
"k3s_letsencrypt_email": "k3s_letsencrypt_email",
"k3s_letsencrypt_endpoint": "k3s_letsencrypt_endpoint",
"k3s_enable_echo": False,
"k3s_app_filename_to_provision": "k3s_app.yaml",
"release_type": "NONE",
"release_main_branch": "main",
"release_current_branch": "my_feature",

View file

@ -20,7 +20,7 @@ def test_c4k_should_calculate_config():
sut = build_devops({})
c4k = sut.specialized_builds[BuildType.C4K]
c4k.update_runtime_config(DnsRecord("fqdn"))
c4k.update_runtime_config(DnsRecord("fqdn", ipv6="::1"))
assert {
"fqdn": "fqdn",
"mon-cfg": {
@ -36,7 +36,7 @@ def test_c4k_should_calculate_config():
}
)
c4k = sut.specialized_builds[BuildType.C4K]
c4k.update_runtime_config(DnsRecord("fqdn"))
c4k.update_runtime_config(DnsRecord("fqdn", ipv6="::1"))
assert {
"test": "test",
"fqdn": "fqdn",

View file

@ -0,0 +1,33 @@
import pytest
from pathlib import Path
from src.main.python.ddadevops.domain import DnsRecord, BuildType, K3s
from .helper import build_devops
def test_creation():
sut = build_devops({})
assert BuildType.K3S in sut.specialized_builds
assert sut.specialized_builds[BuildType.K3S]
def test_should_calculate_provs_config():
sut = build_devops({}).specialized_builds[BuildType.K3S]
sut.update_runtime_config(DnsRecord("example.org", ipv6="::1"))
assert "fqdn:" in sut.provs_config()
assert not "$" in sut.provs_config()
def test_should_calculate_command():
devops = build_devops({})
sut = devops.specialized_builds[BuildType.K3S]
sut.update_runtime_config(DnsRecord("example.org", ipv6="::1"))
assert (
"provs-server.jar "
+ "k3s "
+ "k3s_provision_user@example.org "
+ "-c "
+ "root_path/target/name/module/out_k3sServerConfig.yaml "
+ "-a "
+ "root_path/target/name/module/k3s_app.yaml"
== sut.command(devops)
)