diff --git a/Makefile b/Makefile index 1d9bf3f..a5354dd 100644 --- a/Makefile +++ b/Makefile @@ -26,11 +26,11 @@ clean: pybuild: hatchling build -test_upload: $(PACKAGE_FILE) - twine upload --repository-url https://test.pypi.org/legacy/ dist/cloudbender-*.whl +test_upload: pybuild + twine upload --repository-url https://test.pypi.org/legacy/ --non-interactive dist/cloudbender-*.whl -upload: $(PACKAGE_FILE) - twine upload --repository-url https://upload.pypi.org/legacy/ dist/cloudbender-*.whl +upload: pybuild + twine upload -r pypi --non-interactive dist/cloudbender-*.whl build: podman build --rm -t $(REPOSITORY):$(TAG) -t $(REPOSITORY):latest . diff --git a/cloudbender/cli.py b/cloudbender/cli.py index bc78cec..e709d9e 100644 --- a/cloudbender/cli.py +++ b/cloudbender/cli.py @@ -145,12 +145,12 @@ def outputs(cb, stack_names, multi, include, values): @click.option("--multi", is_flag=True, help="Allow more than one stack to match") @click.option("--graph", is_flag=True, help="Create Dot Graph file") @click.pass_obj -def create_docs(cb, stack_names, multi, graph): - """Parses all documentation fragments out of rendered templates creating docs/*.md file""" +def docs(cb, stack_names, multi, graph): + """Outputs docs for stack(s). For Pulumi stacks prints out docstring. For CloudFormation templates render a markdown file. Same idea as helm-docs.""" stacks = _find_stacks(cb, stack_names, multi) for s in stacks: - s.create_docs(graph=graph) + s.docs(graph=graph) @click.command() @@ -183,19 +183,14 @@ def refresh(cb, stack_name): @click.argument("stack_name") @click.argument("function", default="") @click.argument("args", nargs=-1) -@click.option( - "--listall", - is_flag=True, - help="List all available execute functions for this stack", -) @click.pass_obj -def execute(cb, stack_name, function, args, listall=False): +def execute(cb, stack_name, function, args): """Executes custom Python function within an existing stack context""" stacks = _find_stacks(cb, [stack_name]) for s in stacks: if s.mode == "pulumi": - s.execute(function, args, listall) + s.execute(function, args) else: logger.info( "{} uses Cloudformation, no exec feature available.".format(s.stackname) @@ -455,7 +450,7 @@ cli.add_command(delete) cli.add_command(clean) cli.add_command(create_change_set) cli.add_command(outputs) -cli.add_command(create_docs) +cli.add_command(docs) cli.add_command(refresh) cli.add_command(preview) cli.add_command(set_config) diff --git a/cloudbender/stack.py b/cloudbender/stack.py index 2ce7043..8a57383 100644 --- a/cloudbender/stack.py +++ b/cloudbender/stack.py @@ -574,84 +574,111 @@ class Stack(object): ) ) - def create_docs(self, template=False, graph=False): + @pulumi_ws + def docs(self, template=False, graph=False): """Read rendered template, parse documentation fragments, eg. parameter description - and create a mardown doc file for the stack - same idea as eg. helm-docs for values.yaml + and create a mardown doc file for the stack. Same idea as helm-docs for the values.yaml """ - try: - self.read_template_file() - except FileNotFoundError: - return + if self.mode == "pulumi": + if vars(self._pulumi_code)["__doc__"]: + print(vars(self._pulumi_code)["__doc__"]) + else: + print("No template documentation found.") + + # collect all __doc__ from available _execute_ functions + _help = "" + for k in vars(self._pulumi_code).keys(): + if k.startswith("_execute_"): + docstring = vars(self._pulumi_code)[k].__doc__ + _help = _help + "## {}\n{}\n".format( + k.lstrip("_execute_"), docstring + ) + + if _help: + print(f"# Available `execute` functions: \n\n{_help}") - if not template: - doc_template = importlib.resources.read_text(templates, "stack-doc.md") - jenv = JinjaEnv() - template = jenv.from_string(doc_template) - data = {} else: - doc_template = template - - data["name"] = self.stackname - data["description"] = self.cfn_data["Description"] - data["dependencies"] = self.dependencies - - if "Parameters" in self.cfn_data: - data["parameters"] = self.cfn_data["Parameters"] - set_parameters = self.resolve_parameters() - for p in set_parameters: - data["parameters"][p]["value"] = set_parameters[p] - - if "Outputs" in self.cfn_data: - data["outputs"] = self.cfn_data["Outputs"] - - # Check for existing outputs yaml, if found add current value column and set header to timestamp from outputs file - output_file = os.path.join( - self.ctx["outputs_path"], self.rel_path, self.stackname + ".yaml" - ) - try: - with open(output_file, "r") as yaml_contents: - outputs = yaml.safe_load(yaml_contents.read()) - for p in outputs["Outputs"]: - data["outputs"][p]["last_value"] = outputs["Outputs"][p] - data["timestamp"] = outputs["TimeStamp"] - except (FileNotFoundError, KeyError, TypeError): - pass + self.read_template_file() + except FileNotFoundError: + return - doc_file = os.path.join( - self.ctx["docs_path"], self.rel_path, self.stackname + ".md" - ) - ensure_dir(os.path.join(self.ctx["docs_path"], self.rel_path)) + if not template: + doc_template = importlib.resources.read_text(templates, "stack-doc.md") + jenv = JinjaEnv() + template = jenv.from_string(doc_template) + data = {} + else: + doc_template = template - with open(doc_file, "w") as doc_contents: - doc_contents.write(template.render(**data)) - logger.info("Wrote documentation for %s to %s", self.stackname, doc_file) + data["name"] = self.stackname + data["description"] = self.cfn_data["Description"] + data["dependencies"] = self.dependencies - # Write Graph in Dot format - if graph: - filename = os.path.join( - self.ctx["template_path"], self.rel_path, self.stackname + ".yaml" - ) + if "Parameters" in self.cfn_data: + data["parameters"] = self.cfn_data["Parameters"] + set_parameters = self.resolve_parameters() + for p in set_parameters: + data["parameters"][p]["value"] = set_parameters[p] - lint_args = ["--template", filename] - (args, filenames, formatter) = cfnlint.core.get_args_filenames(lint_args) - (template, rules, matches) = cfnlint.core.get_template_rules(filename, args) - template_obj = cfnlint.template.Template(filename, template, [self.region]) + if "Outputs" in self.cfn_data: + data["outputs"] = self.cfn_data["Outputs"] - path = os.path.join( - self.ctx["docs_path"], self.rel_path, self.stackname + ".dot" - ) - g = cfnlint.graph.Graph(template_obj) - try: - g.to_dot(path) - logger.info("DOT representation of the graph written to %s", path) - except ImportError: - logger.error( - "Could not write the graph in DOT format. Please install either `pygraphviz` or `pydot` modules." + # Check for existing outputs yaml, if found add current value column and set header to timestamp from outputs file + output_file = os.path.join( + self.ctx["outputs_path"], self.rel_path, self.stackname + ".yaml" ) + try: + with open(output_file, "r") as yaml_contents: + outputs = yaml.safe_load(yaml_contents.read()) + for p in outputs["Outputs"]: + data["outputs"][p]["last_value"] = outputs["Outputs"][p] + data["timestamp"] = outputs["TimeStamp"] + except (FileNotFoundError, KeyError, TypeError): + pass + + doc_file = os.path.join( + self.ctx["docs_path"], self.rel_path, self.stackname + ".md" + ) + ensure_dir(os.path.join(self.ctx["docs_path"], self.rel_path)) + + with open(doc_file, "w") as doc_contents: + doc_contents.write(template.render(**data)) + logger.info( + "Wrote documentation for %s to %s", self.stackname, doc_file + ) + + # Write Graph in Dot format + if graph: + filename = os.path.join( + self.ctx["template_path"], self.rel_path, self.stackname + ".yaml" + ) + + lint_args = ["--template", filename] + (args, filenames, formatter) = cfnlint.core.get_args_filenames( + lint_args + ) + (template, rules, matches) = cfnlint.core.get_template_rules( + filename, args + ) + template_obj = cfnlint.template.Template( + filename, template, [self.region] + ) + + path = os.path.join( + self.ctx["docs_path"], self.rel_path, self.stackname + ".dot" + ) + g = cfnlint.graph.Graph(template_obj) + try: + g.to_dot(path) + logger.info("DOT representation of the graph written to %s", path) + except ImportError: + logger.error( + "Could not write the graph in DOT format. Please install either `pygraphviz` or `pydot` modules." + ) + def resolve_parameters(self): """Renders parameters for the stack based on the source template and the environment configuration""" @@ -867,35 +894,29 @@ class Stack(object): return @pulumi_ws - def execute(self, function, args, listall=False): - """Executes custom Python function within a Pulumi stack""" + def execute(self, function, args): + """ + Executes custom Python function within a Pulumi stack - # call all available functions and output built in help - if listall: - for k in vars(self._pulumi_code).keys(): - if k.startswith("_execute_"): - docstring = vars(self._pulumi_code)[k](docstring=True) - print("{}: {}".format(k.lstrip("_execute_"), docstring)) + These functions are executed within the stack environment and are provided with all stack input parameters as well as current outputs. + Think of "docker exec" into an existing container... + + """ + if not function: + logger.error("No function specified !") return + exec_function = f"_execute_{function}" + if exec_function in vars(self._pulumi_code): + pulumi_stack = self._get_pulumi_stack() + vars(self._pulumi_code)[exec_function]( + config=pulumi_stack.get_all_config(), + outputs=pulumi_stack.outputs(), + args=args, + ) + else: - if not function: - logger.error("No function specified !") - return - - exec_function = f"_execute_{function}" - if exec_function in vars(self._pulumi_code): - pulumi_stack = self._get_pulumi_stack() - vars(self._pulumi_code)[exec_function]( - config=pulumi_stack.get_all_config(), - outputs=pulumi_stack.outputs(), - args=args, - ) - - else: - logger.error( - "{} is not defined in {}".format(function, self._pulumi_code) - ) + logger.error("{} is not defined in {}".format(function, self._pulumi_code)) @pulumi_ws def assimilate(self):