Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,33 @@ cfengine build

(This is equivalent to running `cfbs build`).

### Spawn and install cfengine from a config

**this feature is still in work in progress**

Given a yaml config:

```yaml
templates:
ubuntu:
count: 1
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

count should be moved to group, not be in template

Copy link
Copy Markdown
Contributor Author

@victormlg victormlg Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Templates is not a real key in the final config, all the names defined in templates get expanded before analyzing the config. Ex:

templates:
  mycfengine: 
    version: 3.27.0

groups:
  - myhub:
      role: hub
      cfengine: mycfengine

Gets turned into:

groups:
  - myhub:
      role: hub
      cfengine: 
        version: 3.27.0

Also, I believe it makes sense to have "count" inside "spawn", Ex:

groups:
 - client1:
      role: client
      source:
        count: 4
        mode: spawn
        spawn:
          provider: vagrant
          vagrant:
            box: ubuntu/focal64
 
  - client2:
      role: client
      source:
        # count: 4. Here count doesn't make sense, because we have saved hosts
        mode: save
        hosts: [ 8.8.8.8 ]

mode: spawn
spawn:
provider: vagrant
vagrant:
box: ubuntu/focal64

groups:
myhub:
role: hub
source: ubuntu
```

It up will spawn the necessary VMs and install cfengine using cf-remote
```
cfengine up config.yaml
```

## Supported platforms and versions

This tool will only support a limited number of platforms, it is not intended to run everywhere CFEngine runs.
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ dependencies = [
"tree-sitter-cfengine>=1.1.8",
"tree-sitter>=0.25",
"markdown-it-py>=3.0.0",
"pyyaml>=6.0.3",
"pydantic>=2.12.5",
]
classifiers = [
"Development Status :: 3 - Alpha",
Expand Down
19 changes: 19 additions & 0 deletions src/cfengine_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import os
import re
import json
import yaml
from cfengine_cli.profile import profile_cfengine, generate_callstack
from cfengine_cli.dev import dispatch_dev_subcommand
from cfengine_cli.lint import lint_folder, lint_single_arg
Expand All @@ -14,6 +15,7 @@
format_policy_fin_fout,
)
from cfengine_cli.utils import UserError
from cfengine_cli.up import validate_config
from cfbs.utils import find
from cfbs.commands import build_command
from cf_remote.commands import deploy as deploy_command
Expand Down Expand Up @@ -148,3 +150,20 @@ def profile(args) -> int:
generate_callstack(data, args.flamegraph)

return 0


def up(args) -> int:
content = None
try:
with open(args.config, "r") as f:
content = yaml.safe_load(f)
except yaml.YAMLError:
raise UserError("'%s' is not a valid yaml config" % args.config)
except FileNotFoundError:
raise UserError("'%s' doesn't exist" % args.config)

validate_config(content)
if args.validate:
return 0
print("Starting VMs...")
return 0
11 changes: 11 additions & 0 deletions src/cfengine_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ def _get_arg_parser():
dest="minimum_version",
)

up_parser = subp.add_parser(
"up", help="Spawn and install with cf-remote from a yaml config"
)
up_parser.add_argument(
"config", default="config.yaml", nargs="?", help="Path to yaml config"
)
up_parser.add_argument(
"--validate", action="store_true", help="Validate the given config"
)
return ap


Expand Down Expand Up @@ -147,6 +156,8 @@ def run_command_with_args(args) -> int:
return commands.dev(args.dev_command, args)
if args.command == "profile":
return commands.profile(args)
if args.command == "up":
return commands.up(args)
raise UserError(f"Unknown command: '{args.command}'")


Expand Down
225 changes: 225 additions & 0 deletions src/cfengine_cli/up.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
from pydantic import BaseModel, model_validator, ValidationError, Field
from typing import Union, Literal, Optional, List, Annotated
from functools import reduce
from cf_remote import log

import cfengine_cli.validate as validate
from cfengine_cli.utils import UserError


# Forces pydantic to throw validation error if config contains unknown keys
class NoExtra(BaseModel, extra="forbid"):
pass


class Config(NoExtra):
pass


class AWSConfig(Config):
image: str
size: Literal["micro", "xlarge"] = "micro"

@model_validator(mode="after")
def check_aws_config(self):
validate.validate_aws_image(self.image)
return self


class VagrantConfig(Config):
box: str
memory: int = 512
cpus: int = 1
sync_folder: Optional[str] = None
provision: Optional[str] = None

@model_validator(mode="after")
def check_vagrant_config(self):
if self.memory < 512:
raise UserError("Cannot allocate less than 512MB to a Vagrant VM")
if self.cpus < 1:
raise UserError("Cannot use less than 1 cpu per Vagrant VM")

validate.validate_vagrant_box(self.box)

return self


class GCPConfig(Config):
image: str # There is no list of avalaible GCP platforms to validate against yet
network: Optional[str] = None
public_ip: bool = True
size: str = "n1-standard-1"


class AWSProvider(Config):
provider: Literal["aws"]
aws: AWSConfig

@model_validator(mode="after")
def check_aws_provider(self):
validate.validate_aws_credentials()
return self


class GCPProvider(Config):
provider: Literal["gcp"]
gcp: GCPConfig

@model_validator(mode="after")
def check_gcp_provider(self):
validate.validate_gcp_credentials()
return self


class VagrantProvider(Config):
provider: Literal["vagrant"]
vagrant: VagrantConfig


class SaveMode(Config):
mode: Literal["save"]
hosts: List[str]


class SpawnMode(Config):
mode: Literal["spawn"]
# "Field" forces pydantic to report errors on the branch defined by the field "provider"
spawn: Annotated[
Union[VagrantProvider, AWSProvider, GCPProvider],
Field(discriminator="provider"),
]
count: int

@model_validator(mode="after")
def check_spawn_config(self):
if self.count < 1:
raise UserError("Cannot spawn less than 1 instance")
return self


class CFEngineConfig(Config):
version: Optional[str] = None
bootstrap: Optional[str] = None
edition: Literal["community", "enterprise"] = "enterprise"
remote_download: bool = False
hub_package: Optional[str] = None
client_package: Optional[str] = None
package: Optional[str] = None
demo: bool = False

@model_validator(mode="after")
def check_cfengine_config(self):
packages = [self.package, self.hub_package, self.client_package]
for p in packages:
validate.validate_package(p, self.remote_download)

if self.version and any(packages):
log.warning("Specifying package overrides cfengine version")

validate.validate_version(self.version, self.edition)
validate.validate_state_bootstrap(self.bootstrap)

return self


class GroupConfig(Config):
role: Literal["client", "hub"]
# "Field" forces pydantic to report errors on the branch defined by the field "provider"
source: Annotated[Union[SaveMode, SpawnMode], Field(discriminator="mode")]
cfengine: Optional[CFEngineConfig] = None
scripts: Optional[List[str]] = None

@model_validator(mode="after")
def check_group_config(self):
if (
self.role == "hub"
and self.source.mode == "spawn"
and self.source.count != 1
):
raise UserError("A hub can only have one host")

return self


def rgetattr(obj, attr, *args):
def _getattr(obj, attr):
return getattr(obj, attr, *args)

return reduce(_getattr, [obj] + attr.split("."))


class Group:
"""
All group-specific data:
- Vagrantfile
Config that declares it:
- provider, count, cfengine version, role, ...
"""

def __init__(self, config: GroupConfig):
self.config = config
self.hosts = []


class Host:
"""
All host-specific data:
- user, ip, ssh config, OS, uuid, ...
"""

def __init__(self):
pass


def _resolve_templates(parent, templates):
if not parent:
return
if isinstance(parent, dict):
for key, value in parent.items():
if isinstance(value, str) and value in templates:
parent[key] = templates[value]
else:
_resolve_templates(value, templates)
if isinstance(parent, list):
for value in parent:
_resolve_templates(value, templates)


def validate_config(content):
if not content:
raise UserError("Empty spawn config")

if "groups" not in content:
raise UserError("Missing 'groups' key in spawn config")

groups = content["groups"]
templates = content.get("templates")
if templates:
_resolve_templates(groups, templates)

if not isinstance(groups, list):
groups = [groups]

state = {}
try:
for g in groups:
if len(g) != 1:
raise UserError(
"Too many keys in group definition: {}".format(
", ".join(list(g.keys()))
)
)

for k, v in g.items():
state[k] = Group(GroupConfig(**v))

except ValidationError as v:
msgs = []
for err in v.errors():
msgs.append(
"{}. Input '{}' at location '{}'".format(
err["msg"], err["input"], err["loc"]
)
)
raise UserError("\n".join(msgs))
Loading
Loading