فهرست منبع

alloy template update

xcad 4 ماه پیش
والد
کامیت
e77640b863

+ 2 - 3
AGENTS.md

@@ -194,6 +194,5 @@ After creating the issue, update the TODO line in the `AGENTS.md` file with the
 
 ### Work in Progress
 
-* TODO[1247-user-overrides] Add configuration support to allow users to override module and template spec with their own (e.g. defaults -> compose -> spec -> general ...)
-* TODO[1250-compose-deploy] Add compose deploy command to deploy a generated compose project to a local or remote docker environment
-* TODO[1251-centralize-display-logic] Create a DisplayManager class to handle all rich rendering.
+* TODO Add configuration support to allow users to override module and template spec with their own (e.g. defaults -> compose -> spec -> general ...)
+* TODO Add compose deploy command to deploy a generated compose project to a local or remote docker environment

+ 11 - 5
cli/__main__.py

@@ -53,14 +53,20 @@ def setup_logging(log_level: str = "WARNING") -> None:
 def main(
   ctx: Context,
   log_level: Optional[str] = Option(
-    "WARNING", 
-    "--log-level", 
-    help="Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)"
+    None,
+    "--log-level",
+    help="Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). If omitted, logging is disabled."
   )
 ) -> None:
   """Main CLI application for managing boilerplates."""
-  # Configure logging based on the provided log level
-  setup_logging(log_level)
+  # Disable logging by default; only enable when user provides --log-level
+  if log_level:
+    # Re-enable logging and configure
+    logging.disable(logging.NOTSET)
+    setup_logging(log_level)
+  else:
+    # Silence all logging (including third-party) unless user explicitly requests it
+    logging.disable(logging.CRITICAL)
   
   # Store log level in context for potential use by other commands
   ctx.ensure_object(dict)

+ 28 - 12
cli/core/display.py

@@ -30,7 +30,7 @@ class DisplayManager:
         table = Table(title=title)
         table.add_column("ID", style="bold", no_wrap=True)
         table.add_column("Name")
-        table.add_column("Description")
+        table.add_column("Tags")
         table.add_column("Version", no_wrap=True)
         table.add_column("Library", no_wrap=True)
 
@@ -38,12 +38,13 @@ class DisplayManager:
             template = template_info["template"]
             indent = template_info["indent"]
             name = template.metadata.name or "Unnamed Template"
-            desc = template.metadata.description or "No description available"
+            tags_list = template.metadata.tags or []
+            tags = ", ".join(tags_list) if tags_list else "-"
             version = template.metadata.version or ""
             library = template.metadata.library or ""
 
             template_id = f"{indent}{template.id}"
-            table.add_row(template_id, name, desc, version, library)
+            table.add_row(template_id, name, tags, version, library)
 
         console.print(table)
 
@@ -77,7 +78,11 @@ class DisplayManager:
 
     def _display_file_tree(self, template: Template) -> None:
         """Display the file structure of a template."""
-        file_tree = Tree("[bold blue]Template File Structure:[/bold blue]")
+        # Preserve the heading, then use the template id as the root directory label
+        console.print()
+        console.print("[bold blue]Template File Structure:[/bold blue]")
+        # Use the template id as the root directory label (folder glyph + white name)
+        file_tree = Tree(f"\uf07b [white]{template.id}[/white]")
         tree_nodes = {Path("."): file_tree}
 
         for template_file in sorted(
@@ -90,23 +95,34 @@ class DisplayManager:
             for part in parts[:-1]:
                 current_path = current_path / part
                 if current_path not in tree_nodes:
-                    new_node = current_node.add(f"uf07b [bold blue]{part}[/bold blue]")
+                    new_node = current_node.add(f"\uf07b [white]{part}[/white]")
                     tree_nodes[current_path] = new_node
                     current_node = new_node
                 else:
                     current_node = tree_nodes[current_path]
 
+            # Determine display name (use output_path to detect final filename)
+            display_name = template_file.output_path.name if hasattr(template_file, 'output_path') else template_file.relative_path.name
+
+            # Default icons: use Nerd Font private-use-area codepoints (PUA).
+            # Docker (Font Awesome) is typically U+F308. Default file U+F15B.
+            docker_icon = "\uf308"
+            default_file_icon = "\uf15b"
+            j2_icon = "\ue235"
+
             if template_file.file_type == "j2":
-                current_node.add(
-                    f"[green]ue235 {template_file.relative_path.name}[/green]"
-                )
+                # Detect common docker compose filenames from the resulting output path
+                lower_name = display_name.lower()
+                compose_names = {"docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"}
+                if lower_name in compose_names or lower_name.startswith("docker-compose") or "compose" in lower_name:
+                    icon = docker_icon
+                else:
+                    icon = j2_icon
+                current_node.add(f"[white]{icon} {display_name}[/white]")
             elif template_file.file_type == "static":
-                current_node.add(
-                    f"[yellow]uf15b {template_file.relative_path.name}[/yellow]"
-                )
+                current_node.add(f"[white]{default_file_icon} {display_name}[/white]")
 
         if file_tree.children:
-            console.print()
             console.print(file_tree)
 
     def _display_variables_table(self, template: Template) -> None:

+ 3 - 2
cli/core/module.py

@@ -8,7 +8,7 @@ from typing import Any, Optional
 from rich.console import Console
 from rich.panel import Panel
 from rich.prompt import Confirm
-from typer import Argument, Context, Option, Typer
+from typer import Argument, Context, Option, Typer, Exit
 
 from .display import DisplayManager
 from .library import LibraryManager
@@ -242,7 +242,8 @@ class Module(ABC):
     except Exception as e:
       logger.error(f"Error rendering template '{id}': {e}")
       console.print(f"[red]Error generating template: {e}[/red]")
-      raise
+      # Stop execution without letting Typer/Click print the exception again.
+      raise Exit(code=1)
 
   # !SECTION
 

+ 89 - 16
cli/core/prompt.py

@@ -76,7 +76,8 @@ class PromptHandler:
           continue
           
         current_value = variable.get_typed_value()
-        new_value = self._prompt_variable(variable)
+        # Pass section.required so _prompt_variable can enforce required inputs
+        new_value = self._prompt_variable(variable, required=section.required)
         
         if new_value != current_value:
           collected[var_name] = new_value
@@ -91,40 +92,100 @@ class PromptHandler:
   # SECTION: Private Methods
   # ---------------------------
 
-  def _prompt_variable(self, variable: Variable) -> Any:
+  def _prompt_variable(self, variable: Variable, required: bool = False) -> Any:
     """Prompt for a single variable value based on its type."""
     logger.debug(f"Prompting for variable '{variable.name}' (type: {variable.type})")
-
     prompt_text = variable.prompt or variable.description or variable.name
 
-    # Friendly hint for common semantic types
-    if variable.type in ["hostname", "email", "url"]:
+    # Normalize default value once and reuse. This centralizes handling for
+    # enums, bools, ints and strings and avoids duplicated fallback logic.
+    default_value = self._normalize_default(variable)
+
+    # Friendly hint for common semantic types — only show if a default exists
+    if default_value is not None and variable.type in ["hostname", "email", "url"]:
       prompt_text += f" ({variable.type})"
 
-    try:
-      default_value = variable.get_typed_value()
-    except ValueError:
-      default_value = variable.value
+    # If variable is required and there's no default, mark it in the prompt
+    if required and default_value is None:
+      prompt_text = f"{prompt_text} [bold red]*required[/bold red]"
 
     handler = self._get_prompt_handler(variable)
 
+    # Attach the optional 'extra' explanation inline (dimmed) so it appears
+    # after the main question rather than before it.
+    if getattr(variable, 'extra', None):
+      # Put the extra hint inline (same line) instead of on the next line.
+      prompt_text = f"{prompt_text} [dim]{variable.extra}[/dim]"
+
     while True:
       try:
         raw = handler(prompt_text, default_value)
-        return variable.convert(raw)
+        # Convert/validate the user's input using the Variable conversion
+        converted = variable.convert(raw)
+
+        # If this variable is required, do not accept None/empty values
+        if required and (converted is None or (isinstance(converted, str) and converted == "")):
+          raise ValueError("value cannot be empty for required variable")
+
+        # Return the converted value (caller will update variable.value)
+        return converted
       except ValueError as exc:
+        # Conversion/validation failed — show a consistent error message and retry
         self._show_validation_error(str(exc))
       except Exception as e:
+        # Unexpected error — log and retry using the stored (unconverted) value
         logger.error(f"Error prompting for variable '{variable.name}': {str(e)}")
         default_value = variable.value
         handler = self._get_prompt_handler(variable)
 
+  def _normalize_default(self, variable: Variable) -> Any:
+    """Return a normalized default suitable for prompt handlers.
+
+    Tries to use the typed value if available, otherwise falls back to the raw
+    stored value. For enums, ensures the default is one of the options.
+    """
+    try:
+      typed = variable.get_typed_value()
+    except Exception:
+      typed = variable.value
+
+    # Special-case enums: ensure default is valid
+    if variable.type == "enum":
+      options = variable.options or []
+      if not options:
+        return typed
+      # If typed is falsy or not in options, pick first option as fallback
+      if typed is None or str(typed) not in options:
+        return options[0]
+      return str(typed)
+
+    # For booleans and ints return as-is (handlers will accept these types)
+    if variable.type == "bool":
+      if isinstance(typed, bool):
+        return typed
+      if typed is None:
+        return None
+      return bool(typed)
+
+    if variable.type == "int":
+      try:
+        return int(typed) if typed is not None and typed != "" else None
+      except Exception:
+        return None
+
+    # Default: return string or None
+    if typed is None:
+      return None
+    return str(typed)
+
   def _get_prompt_handler(self, variable: Variable) -> Callable:
     """Return the prompt function for a variable type."""
     handlers = {
       "bool": self._prompt_bool,
       "int": self._prompt_int,
-      "enum": lambda text, default: self._prompt_enum(text, variable.options or [], default),
+      # For enum prompts we pass the variable.extra through so options and extra
+      # can be combined into a single inline hint.
+      "enum": lambda text, default: self._prompt_enum(text, variable.options or [], default, extra=getattr(variable, 'extra', None)),
     }
     return handlers.get(variable.type, lambda text, default: self._prompt_string(text, default, is_sensitive=variable.sensitive))
 
@@ -139,7 +200,10 @@ class PromptHandler:
       show_default=True,
       password=is_sensitive
     )
-    return value.strip() if value else ""
+    if value is None:
+      return None
+    stripped = value.strip()
+    return stripped if stripped != "" else None
 
   def _prompt_bool(self, prompt_text: str, default: Any = None) -> bool:
     default_bool = None
@@ -156,12 +220,21 @@ class PromptHandler:
         logger.warning(f"Invalid default integer value: {default}")
     return IntPrompt.ask(prompt_text, default=default_int)
 
-  def _prompt_enum(self, prompt_text: str, options: list[str], default: Any = None) -> str:
-    """Prompt for enum selection with validation."""
+  def _prompt_enum(self, prompt_text: str, options: list[str], default: Any = None, extra: str | None = None) -> str:
+    """Prompt for enum selection with validation. """
     if not options:
       return self._prompt_string(prompt_text, default)
 
-    self.console.print(f"  Options: {', '.join(options)}", style="dim")
+    # Build a single inline hint that contains both the options and any extra
+    # explanation, rendered dimmed and appended to the prompt on one line.
+    hint_parts: list[str] = []
+    hint_parts.append(f"Options: {', '.join(options)}")
+    if extra:
+      hint_parts.append(extra)
+
+    # Show options and extra inline (same line) in a single dimmed block.
+    options_text = f" [dim]{' — '.join(hint_parts)}[/dim]"
+    prompt_text_with_options = prompt_text + options_text
 
     # Validate default is in options
     if default and str(default) not in options:
@@ -169,7 +242,7 @@ class PromptHandler:
 
     while True:
       value = Prompt.ask(
-        prompt_text,
+        prompt_text_with_options,
         default=str(default) if default else options[0],
         show_default=True,
       )

+ 94 - 2
cli/core/template.py

@@ -7,6 +7,8 @@ from dataclasses import dataclass, field
 import logging
 import os
 from jinja2 import Environment, FileSystemLoader, meta
+from jinja2 import nodes
+from jinja2.visitor import NodeVisitor
 import frontmatter
 
 logger = logging.getLogger(__name__)
@@ -51,7 +53,14 @@ class TemplateMetadata:
     metadata_section = post.metadata.get("metadata", {})
     
     self.name = metadata_section.get("name", "")
-    self.description = metadata_section.get("description", "No description available")
+    # YAML block scalar (|) preserves a trailing newline. Remove only trailing newlines
+    # while preserving internal newlines/formatting.
+    raw_description = metadata_section.get("description", "")
+    if isinstance(raw_description, str):
+      description = raw_description.rstrip("\n")
+    else:
+      description = str(raw_description)
+    self.description = description or "No description available"
     self.author = metadata_section.get("author", "")
     self.date = metadata_section.get("date", "")
     self.version = metadata_section.get("version", "")
@@ -156,7 +165,27 @@ class Template:
           merged_section[key] = template_section[key]
       module_vars = module_section.get('vars') if isinstance(module_section.get('vars'), dict) else {}
       template_vars = template_section.get('vars') if isinstance(template_section.get('vars'), dict) else {}
-      merged_section['vars'] = {**module_vars, **template_vars}
+
+      # Deep-merge variables while preserving the ordering from the module spec.
+      # Template-only variables are appended at the end in template order.
+      from collections import OrderedDict
+
+      merged_vars: OrderedDict = OrderedDict()
+
+      # First, keep module variables in their original order, merging any
+      # template-provided keys for the same variable.
+      for var_name, mod_var in module_vars.items():
+        mod_var = mod_var or {}
+        tmpl_var = template_vars.get(var_name, {}) or {}
+        merged_vars[var_name] = {**mod_var, **tmpl_var}
+
+      # Then, append any template-only variables in the template's order.
+      for var_name, tmpl_var in template_vars.items():
+        if var_name in merged_vars:
+          continue
+        merged_vars[var_name] = {**(tmpl_var or {})}
+
+      merged_section['vars'] = merged_vars
       merged_specs[section_key] = merged_section
     
     for section_key in template_specs.keys():
@@ -204,6 +233,54 @@ class Template:
           logger.warning(f"Could not parse Jinja2 variables from {file_path}: {e}")
     return used_variables
 
+  def _extract_jinja_default_values(self) -> dict[str, object]:
+    """Scan all .j2 files and extract literal arguments to the `default` filter.
+
+    Returns a mapping var_name -> literal_value for simple cases like
+    {{ var | default("value") }} or {{ var | default(123) }}.
+    This does not attempt to evaluate complex expressions.
+    """
+    defaults: dict[str, object] = {}
+
+    class _DefaultVisitor(NodeVisitor):
+      def __init__(self):
+        self.found: dict[str, object] = {}
+
+      def visit_Filter(self, node: nodes.Filter) -> None:  # type: ignore[override]
+        try:
+          if getattr(node, 'name', None) == 'default' and node.args:
+            # target variable name when filter is applied directly to a Name
+            target = None
+            if isinstance(node.node, nodes.Name):
+              target = node.node.name
+
+            # first arg literal
+            first = node.args[0]
+            if isinstance(first, nodes.Const) and target:
+              self.found[target] = first.value
+        except Exception:
+          # Be resilient to unexpected node shapes
+          pass
+        # continue traversal
+        self.generic_visit(node)
+
+    visitor = _DefaultVisitor()
+
+    for template_file in self.template_files:
+      if template_file.file_type != 'j2':
+        continue
+      file_path = self.template_dir / template_file.relative_path
+      try:
+        with open(file_path, 'r', encoding='utf-8') as f:
+          content = f.read()
+        ast = self.jinja_env.parse(content)
+        visitor.visit(ast)
+      except Exception:
+        # skip failures - this extraction is best-effort only
+        continue
+
+    return visitor.found
+
   def _filter_specs_to_used(self, used_variables: set, merged_specs: dict, module_specs: dict, template_specs: dict) -> dict:
     """Filter specs to only include variables used in the templates."""
     filtered_specs = {}
@@ -378,5 +455,20 @@ class Template:
           self._validate_variable_definitions(self.used_variables, self.merged_specs)
           # Filter specs to only used variables
           filtered_specs = self._filter_specs_to_used(self.used_variables, self.merged_specs, self.module_specs, self.template_specs)
+
+          # Best-effort: extract literal defaults from Jinja `default()` filter and
+          # merge them into the filtered_specs when no default exists there.
+          try:
+            jinja_defaults = self._extract_jinja_default_values()
+            for section_key, section_data in filtered_specs.items():
+              vars_dict = section_data.get('vars', {})
+              for var_name, var_data in vars_dict.items():
+                if 'default' not in var_data or var_data.get('default') in (None, ''):
+                  if var_name in jinja_defaults:
+                    var_data['default'] = jinja_defaults[var_name]
+          except Exception:
+            # keep behavior stable on any extraction errors
+            pass
+
           self.__variables = VariableCollection(filtered_specs)
       return self.__variables

+ 33 - 6
cli/core/variables.py

@@ -54,6 +54,8 @@ class Variable:
     self.section: Optional[str] = data.get("section")
     self.origin: Optional[str] = data.get("origin")
     self.sensitive: bool = data.get("sensitive", False)
+    # Optional extra explanation used by interactive prompts
+    self.extra: Optional[str] = data.get("extra")
 
     # Validate and convert the default/initial value if present
     if self.value is not None:
@@ -97,6 +99,10 @@ class Variable:
     if value is None:
       return None
 
+    # Treat empty strings as None to avoid storing "" for missing values.
+    if isinstance(value, str) and value.strip() == "":
+      return None
+
     # Type conversion mapping for cleaner code
     converters = {
       "bool": self._convert_bool,
@@ -161,7 +167,7 @@ class Variable:
     """Convert and validate hostname."""
     val = str(value).strip()
     if not val:
-      return ""
+      return None
     if val.lower() != "localhost":
       self._validate_regex_pattern(val, HOSTNAME_REGEX, "value must be a valid hostname")
     return val
@@ -170,7 +176,7 @@ class Variable:
     """Convert and validate URL."""
     val = str(value).strip()
     if not val:
-      return ""
+      return None
     parsed = urlparse(val)
     self._validate_url_structure(parsed)
     return val
@@ -179,7 +185,7 @@ class Variable:
     """Convert and validate email."""
     val = str(value).strip()
     if not val:
-      return ""
+      return None
     self._validate_regex_pattern(val, EMAIL_REGEX, "value must be a valid email address")
     return val
 
@@ -373,6 +379,8 @@ class VariableCollection:
   
   def validate_all(self) -> None:
     """Validate all variables in the collection, skipping disabled sections."""
+    errors: list[str] = []
+
     for section in self._sections.values():
       # Check if the section is disabled by a toggle
       if section.toggle:
@@ -381,9 +389,28 @@ class VariableCollection:
           logger.debug(f"Skipping validation for disabled section: '{section.key}'")
           continue  # Skip this entire section
 
-      # NOTE: Skip individual variable validation since we removed the validate method
-      # All validation now happens during conversion in the Variable.convert() method
-      pass
+      # Validate each variable in the section
+      for var_name, variable in section.variables.items():
+        try:
+          # If value is None, treat as missing
+          if variable.value is None:
+            errors.append(f"{section.key}.{var_name} (missing)")
+            continue
+
+          # Attempt to convert/validate typed value
+          typed = variable.get_typed_value()
+
+          # For non-boolean types, treat None or empty string as invalid
+          if variable.type not in ("bool",) and (typed is None or typed == ""):
+            errors.append(f"{section.key}.{var_name} (empty)")
+
+        except ValueError as e:
+          errors.append(f"{section.key}.{var_name} (invalid: {e})")
+
+    if errors:
+      error_msg = "Variable validation failed: " + ", ".join(errors)
+      logger.error(error_msg)
+      raise ValueError(error_msg)
 
   # !SECTION
 

+ 1 - 1
library/compose/alloy/compose.yaml.j2

@@ -13,7 +13,7 @@ services:
       - "12345:12345"
     {% endif %}
     volumes:
-      - ./config.alloy:/etc/alloy/config.alloy
+      - ./config/config.alloy:/etc/alloy/config.alloy
       - alloy_data:/var/lib/alloy/data
       - /:/rootfs:ro
       - /run:/run:ro

+ 0 - 0
library/compose/alloy/config.alloy → library/compose/alloy/config/config.alloy


+ 12 - 11
library/compose/alloy/template.yaml

@@ -1,21 +1,22 @@
 ---
 kind: compose
 metadata:
-  name: Alloy
-  description: Docker compose setup for alloy
-  version: 0.1.0
+  name: Grafana Alloy
+  description: |
+    Open-source telemetry collector and a distribution of the OpenTelemetry Collector, designed to gather and process logs, metrics, traces, and profiles from your applications and infrastructure. It integrates features from the OpenTelemetry Collector and Prometheus, offering programmable pipelines with a rich, expression-based syntax and support for multiple observability ecosystems. Alloy functions as a powerful, high-performance, and vendor-neutral tool for observability, acting as a successor to the Grafana Agent and providing enhanced features for scaling and managing telemetry data.
+
+    Project: https://grafana.com/docs/alloy/
+    Source: https://github.com/grafana/alloy
+    Documentation: https://grafana.com/docs/alloy/latest/
+  version: 0.0.1
   author: Christian Lempa
   date: '2025-09-28'
   tags:
-  - alloy
-  - docker
-  - compose
+    - monitoring
+    - grafana
 spec:
   general:
     vars:
-      alloy_version:
-        type: string
-        description: Alloy version
-        default: latest
-
+      container_hostname:
+        extra: This is needed because when alloy runs in a container, it doesn't know the hostname of the docker host.
 ---