Skip to content

Commit a226367

Browse files
committed
Implemented spawn config validation
Signed-off-by: Victor Moene <victor.moene@northern.tech>
1 parent edc31ce commit a226367

File tree

6 files changed

+607
-0
lines changed

6 files changed

+607
-0
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ dependencies = [
1515
"tree-sitter-cfengine>=1.1.8",
1616
"tree-sitter>=0.25",
1717
"markdown-it-py>=3.0.0",
18+
"pyyaml>=6.0.3",
19+
"pydantic>=2.12.5",
1820
]
1921
classifiers = [
2022
"Development Status :: 3 - Alpha",

src/cfengine_cli/commands.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33
import re
44
import json
5+
import yaml
56
from cfengine_cli.profile import profile_cfengine, generate_callstack
67
from cfengine_cli.dev import dispatch_dev_subcommand
78
from cfengine_cli.lint import lint_folder, lint_single_arg
@@ -14,6 +15,7 @@
1415
format_policy_fin_fout,
1516
)
1617
from cfengine_cli.utils import UserError
18+
from cfengine_cli.up import validate_config
1719
from cfbs.utils import find
1820
from cfbs.commands import build_command
1921
from cf_remote.commands import deploy as deploy_command
@@ -148,3 +150,18 @@ def profile(args) -> int:
148150
generate_callstack(data, args.flamegraph)
149151

150152
return 0
153+
154+
155+
def up(config) -> int:
156+
content = None
157+
try:
158+
with open(config, "r") as f:
159+
content = yaml.safe_load(f)
160+
except yaml.YAMLError:
161+
raise UserError("'%s' is not a valid yaml config" % config)
162+
except FileNotFoundError:
163+
raise UserError("'%s' doesn't exist" % config)
164+
165+
print("Starting VMs...")
166+
validate_config(content)
167+
return 0

src/cfengine_cli/main.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,12 @@ def _get_arg_parser():
114114
dest="minimum_version",
115115
)
116116

117+
up_parser = subp.add_parser(
118+
"up", help="Spawn and install with cf-remote from a yaml config"
119+
)
120+
up_parser.add_argument(
121+
"config", default="config.yaml", nargs="?", help="Path to yaml config"
122+
)
117123
return ap
118124

119125

@@ -147,6 +153,8 @@ def run_command_with_args(args) -> int:
147153
return commands.dev(args.dev_command, args)
148154
if args.command == "profile":
149155
return commands.profile(args)
156+
if args.command == "up":
157+
return commands.up(args.config)
150158
raise UserError(f"Unknown command: '{args.command}'")
151159

152160

src/cfengine_cli/up.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
from pydantic import BaseModel, model_validator, ValidationError, Field
2+
from typing import Union, Literal, Optional, List, Annotated
3+
from functools import reduce
4+
from cf_remote import log
5+
6+
import cfengine_cli.validate as validate
7+
from cfengine_cli.utils import UserError
8+
9+
10+
# Forces pydantic to throw validation error if config contains unknown keys
11+
class NoExtra(BaseModel, extra="forbid"):
12+
pass
13+
14+
15+
class Config(NoExtra):
16+
pass
17+
18+
19+
class AWSConfig(Config):
20+
image: str
21+
size: Literal["micro", "xlarge"] = "micro"
22+
23+
@model_validator(mode="after")
24+
def check_aws_config(self):
25+
validate.validate_aws_image(self.image)
26+
return self
27+
28+
29+
class VagrantConfig(Config):
30+
box: str
31+
memory: int = 512
32+
cpus: int = 1
33+
sync_folder: Optional[str] = None
34+
provision: Optional[str] = None
35+
36+
@model_validator(mode="after")
37+
def check_vagrant_config(self):
38+
if self.memory < 512:
39+
raise UserError("Cannot allocate less than 512MB to a Vagrant VM")
40+
if self.cpus < 1:
41+
raise UserError("Cannot use less than 1 cpu per Vagrant VM")
42+
43+
validate.validate_vagrant_box(self.box)
44+
45+
return self
46+
47+
48+
class GCPConfig(Config):
49+
image: str # There is no list of avalaible GCP platforms to validate against yet
50+
network: Optional[str] = None
51+
public_ip: bool = True
52+
size: str = "n1-standard-1"
53+
54+
55+
class AWSProvider(Config):
56+
provider: Literal["aws"]
57+
aws: AWSConfig
58+
59+
@model_validator(mode="after")
60+
def check_aws_provider(self):
61+
validate.validate_aws_credentials()
62+
return self
63+
64+
65+
class GCPProvider(Config):
66+
provider: Literal["gcp"]
67+
gcp: GCPConfig
68+
69+
@model_validator(mode="after")
70+
def check_gcp_provider(self):
71+
validate.validate_gcp_credentials()
72+
return self
73+
74+
75+
class VagrantProvider(Config):
76+
provider: Literal["vagrant"]
77+
vagrant: VagrantConfig
78+
79+
80+
class SaveMode(Config):
81+
mode: Literal["save"]
82+
hosts: List[str]
83+
84+
85+
class SpawnMode(Config):
86+
mode: Literal["spawn"]
87+
# "Field" forces pydantic to report errors on the branch defined by the field "provider"
88+
spawn: Annotated[
89+
Union[VagrantProvider, AWSProvider, GCPProvider],
90+
Field(discriminator="provider"),
91+
]
92+
count: int
93+
94+
@model_validator(mode="after")
95+
def check_spawn_config(self):
96+
if self.count < 1:
97+
raise UserError("Cannot spawn less than 1 instance")
98+
return self
99+
100+
101+
class CFEngineConfig(Config):
102+
version: Optional[str] = None
103+
bootstrap: Optional[str] = None
104+
edition: Literal["community", "enterprise"] = "enterprise"
105+
remote_download: bool = False
106+
hub_package: Optional[str] = None
107+
client_package: Optional[str] = None
108+
package: Optional[str] = None
109+
demo: bool = False
110+
111+
@model_validator(mode="after")
112+
def check_cfengine_config(self):
113+
packages = [self.package, self.hub_package, self.client_package]
114+
for p in packages:
115+
validate.validate_package(p, self.remote_download)
116+
117+
if self.version and any(packages):
118+
log.warning("Specifying package overrides cfengine version")
119+
120+
validate.validate_version(self.version, self.edition)
121+
validate.validate_state_bootstrap(self.bootstrap)
122+
123+
return self
124+
125+
126+
class GroupConfig(Config):
127+
role: Literal["client", "hub"]
128+
# "Field" forces pydantic to report errors on the branch defined by the field "provider"
129+
source: Annotated[Union[SaveMode, SpawnMode], Field(discriminator="mode")]
130+
cfengine: Optional[CFEngineConfig] = None
131+
scripts: Optional[List[str]] = None
132+
133+
@model_validator(mode="after")
134+
def check_group_config(self):
135+
if (
136+
self.role == "hub"
137+
and self.source.mode == "spawn"
138+
and self.source.count != 1
139+
):
140+
raise UserError("A hub can only have one host")
141+
142+
return self
143+
144+
145+
def rgetattr(obj, attr, *args):
146+
def _getattr(obj, attr):
147+
return getattr(obj, attr, *args)
148+
149+
return reduce(_getattr, [obj] + attr.split("."))
150+
151+
152+
class Group:
153+
"""
154+
All group-specific data:
155+
- Vagrantfile
156+
Config that declares it:
157+
- provider, count, cfengine version, role, ...
158+
"""
159+
160+
def __init__(self, config: GroupConfig):
161+
self.config = config
162+
self.hosts = []
163+
164+
165+
class Host:
166+
"""
167+
All host-specific data:
168+
- user, ip, ssh config, OS, uuid, ...
169+
"""
170+
171+
def __init__(self):
172+
pass
173+
174+
175+
def _resolve_templates(parent, templates):
176+
if not parent:
177+
return
178+
if isinstance(parent, dict):
179+
for key, value in parent.items():
180+
if isinstance(value, str) and value in templates:
181+
parent[key] = templates[value]
182+
else:
183+
_resolve_templates(value, templates)
184+
if isinstance(parent, list):
185+
for value in parent:
186+
_resolve_templates(value, templates)
187+
188+
189+
def validate_config(content):
190+
if not content:
191+
raise UserError("Empty spawn config")
192+
193+
if "groups" not in content:
194+
raise UserError("Missing 'groups' key in spawn config")
195+
196+
groups = content["groups"]
197+
templates = content.get("templates")
198+
if templates:
199+
_resolve_templates(groups, templates)
200+
201+
if not isinstance(groups, list):
202+
groups = [groups]
203+
204+
state = {}
205+
try:
206+
for g in groups:
207+
if len(g) != 1:
208+
raise UserError(
209+
"Too many keys in group definition: {}".format(
210+
", ".join(list(g.keys()))
211+
)
212+
)
213+
214+
for k, v in g.items():
215+
state[k] = Group(GroupConfig(**v))
216+
217+
except ValidationError as v:
218+
msgs = []
219+
for err in v.errors():
220+
msgs.append(
221+
"{}. Input '{}' at location '{}'".format(
222+
err["msg"], err["input"], err["loc"]
223+
)
224+
)
225+
raise UserError("\n".join(msgs))

0 commit comments

Comments
 (0)