Some checks failed
Lint Checks / Run linter (pull_request) Successful in 2m5s
Deploy Test / Run deploy test suite (pull_request) Successful in 4m43s
K8s Deploy Test / Run deploy test suite on kind/k8s (pull_request) Failing after 5m4s
K8s Deployment Control Test / Run deployment control suite on kind/k8s (pull_request) Failing after 6m26s
Webapp Test / Run webapp test suite (pull_request) Successful in 7m36s
Smoke Test / Run basic test suite (pull_request) Successful in 5m32s
resolve_job_compose_file() used Path(stack).parent.parent for the internal fallback, which resolved to data/stacks/compose-jobs/ instead of data/compose-jobs/. This meant deploy create couldn't find job compose files for internal stacks, so they were never copied to the deployment directory and never created as k8s Jobs. Use the same data directory resolution pattern as resolve_compose_file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
301 lines
10 KiB
Python
301 lines
10 KiB
Python
# Copyright © 2022, 2023 Vulcanize
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU Affero General Public License for more details.
|
|
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http:#www.gnu.org/licenses/>.
|
|
|
|
from decouple import config
|
|
import os.path
|
|
import sys
|
|
import ruamel.yaml
|
|
from pathlib import Path
|
|
from dotenv import dotenv_values
|
|
from typing import Mapping, NoReturn, Optional, Set, List
|
|
from stack_orchestrator.constants import stack_file_name, deployment_file_name
|
|
|
|
|
|
def include_exclude_check(s, include, exclude):
|
|
if include is None and exclude is None:
|
|
return True
|
|
if include is not None:
|
|
include_list = include.split(",")
|
|
return s in include_list
|
|
if exclude is not None:
|
|
exclude_list = exclude.split(",")
|
|
return s not in exclude_list
|
|
|
|
|
|
def get_stack_path(stack):
|
|
if stack_is_external(stack):
|
|
stack_path = Path(stack)
|
|
else:
|
|
# In order to be compatible with Python 3.8 we need to use this hack
|
|
# to get the path:
|
|
# See: https://stackoverflow.com/questions/25389095/
|
|
# python-get-path-of-root-project-structure
|
|
stack_path = Path(__file__).absolute().parent.joinpath("data", "stacks", stack)
|
|
return stack_path
|
|
|
|
|
|
def get_dev_root_path(ctx):
|
|
if ctx and ctx.local_stack:
|
|
# TODO: This code probably doesn't work
|
|
dev_root_path = os.getcwd()[0 : os.getcwd().rindex("stack-orchestrator")]
|
|
print(
|
|
f"Local stack dev_root_path (CERC_REPO_BASE_DIR) overridden to: "
|
|
f"{dev_root_path}"
|
|
)
|
|
else:
|
|
dev_root_path = os.path.expanduser(
|
|
str(config("CERC_REPO_BASE_DIR", default="~/cerc"))
|
|
)
|
|
return dev_root_path
|
|
|
|
|
|
# Caller can pass either the name of a stack, or a path to a stack file
|
|
def get_parsed_stack_config(stack):
|
|
stack_file_path = get_stack_path(stack).joinpath(stack_file_name)
|
|
if stack_file_path.exists():
|
|
return get_yaml().load(open(stack_file_path, "r"))
|
|
# We try here to generate a useful diagnostic error
|
|
# First check if the stack directory is present
|
|
if stack_file_path.parent.exists():
|
|
error_exit(f"stack.yml file is missing from stack: {stack}")
|
|
error_exit(f"stack {stack} does not exist")
|
|
|
|
|
|
def get_pod_list(parsed_stack):
|
|
# Handle both old and new format
|
|
if "pods" not in parsed_stack or not parsed_stack["pods"]:
|
|
return []
|
|
pods = parsed_stack["pods"]
|
|
if type(pods[0]) is str:
|
|
result = pods
|
|
else:
|
|
result = []
|
|
for pod in pods:
|
|
result.append(pod["name"])
|
|
return result
|
|
|
|
|
|
def get_job_list(parsed_stack):
|
|
# Return list of jobs from stack config, or empty list if no jobs defined
|
|
if "jobs" not in parsed_stack:
|
|
return []
|
|
jobs = parsed_stack["jobs"]
|
|
if not jobs:
|
|
return []
|
|
if type(jobs[0]) is str:
|
|
result = jobs
|
|
else:
|
|
result = []
|
|
for job in jobs:
|
|
result.append(job["name"])
|
|
return result
|
|
|
|
|
|
def get_plugin_code_paths(stack) -> List[Path]:
|
|
parsed_stack = get_parsed_stack_config(stack)
|
|
pods = parsed_stack.get("pods") or []
|
|
result: Set[Path] = set()
|
|
for pod in pods:
|
|
if type(pod) is str:
|
|
result.add(get_stack_path(stack))
|
|
else:
|
|
pod_root_dir = os.path.join(
|
|
get_dev_root_path(None), pod["repository"].split("/")[-1], pod["path"]
|
|
)
|
|
result.add(Path(os.path.join(pod_root_dir, "stack")))
|
|
return list(result)
|
|
|
|
|
|
# # Find a config directory, looking first in any external stack
|
|
# and if not found there, internally
|
|
def resolve_config_dir(stack, config_dir_name: str):
|
|
if stack_is_external(stack):
|
|
# First try looking in the external stack for the compose file
|
|
config_base = Path(stack).parent.parent.joinpath("config")
|
|
proposed_dir = config_base.joinpath(config_dir_name)
|
|
if proposed_dir.exists():
|
|
return proposed_dir
|
|
# If we don't find it fall through to the internal case
|
|
config_base = get_internal_config_dir()
|
|
return config_base.joinpath(config_dir_name)
|
|
|
|
|
|
# Find a compose file, looking first in any external stack
|
|
# and if not found there, internally
|
|
def resolve_compose_file(stack, pod_name: str):
|
|
if stack_is_external(stack):
|
|
# First try looking in the external stack for the compose file
|
|
compose_base = Path(stack).parent.parent.joinpath("compose")
|
|
proposed_file = compose_base.joinpath(f"docker-compose-{pod_name}.yml")
|
|
if proposed_file.exists():
|
|
return proposed_file
|
|
# If we don't find it fall through to the internal case
|
|
compose_base = get_internal_compose_file_dir()
|
|
return compose_base.joinpath(f"docker-compose-{pod_name}.yml")
|
|
|
|
|
|
# Find a job compose file in compose-jobs directory
|
|
def resolve_job_compose_file(stack, job_name: str):
|
|
if stack_is_external(stack):
|
|
# First try looking in the external stack for the job compose file
|
|
compose_jobs_base = Path(stack).parent.parent.joinpath("compose-jobs")
|
|
proposed_file = compose_jobs_base.joinpath(f"docker-compose-{job_name}.yml")
|
|
if proposed_file.exists():
|
|
return proposed_file
|
|
# If we don't find it fall through to the internal case
|
|
data_dir = Path(__file__).absolute().parent.joinpath("data")
|
|
compose_jobs_base = data_dir.joinpath("compose-jobs")
|
|
return compose_jobs_base.joinpath(f"docker-compose-{job_name}.yml")
|
|
|
|
|
|
def get_pod_file_path(stack, parsed_stack, pod_name: str):
|
|
pods = parsed_stack.get("pods") or []
|
|
result = None
|
|
if not pods:
|
|
return result
|
|
if type(pods[0]) is str:
|
|
result = resolve_compose_file(stack, pod_name)
|
|
else:
|
|
for pod in pods:
|
|
if pod["name"] == pod_name:
|
|
pod_root_dir = os.path.join(
|
|
get_dev_root_path(None),
|
|
pod["repository"].split("/")[-1],
|
|
pod["path"],
|
|
)
|
|
result = os.path.join(pod_root_dir, "docker-compose.yml")
|
|
return result
|
|
|
|
|
|
def get_job_file_path(stack, parsed_stack, job_name: str):
|
|
if "jobs" not in parsed_stack or not parsed_stack["jobs"]:
|
|
return None
|
|
jobs = parsed_stack["jobs"]
|
|
if type(jobs[0]) is str:
|
|
result = resolve_job_compose_file(stack, job_name)
|
|
else:
|
|
# TODO: Support complex job definitions if needed
|
|
result = resolve_job_compose_file(stack, job_name)
|
|
return result
|
|
|
|
|
|
def get_pod_script_paths(parsed_stack, pod_name: str):
|
|
pods = parsed_stack.get("pods") or []
|
|
result = []
|
|
if not pods or not type(pods[0]) is str:
|
|
for pod in pods:
|
|
if pod["name"] == pod_name:
|
|
pod_root_dir = os.path.join(
|
|
get_dev_root_path(None),
|
|
pod["repository"].split("/")[-1],
|
|
pod["path"],
|
|
)
|
|
if "pre_start_command" in pod:
|
|
result.append(os.path.join(pod_root_dir, pod["pre_start_command"]))
|
|
if "post_start_command" in pod:
|
|
result.append(os.path.join(pod_root_dir, pod["post_start_command"]))
|
|
return result
|
|
|
|
|
|
def pod_has_scripts(parsed_stack, pod_name: str):
|
|
pods = parsed_stack.get("pods") or []
|
|
result = False
|
|
if not pods or type(pods[0]) is str:
|
|
result = False
|
|
else:
|
|
for pod in pods:
|
|
if pod["name"] == pod_name:
|
|
result = "pre_start_command" in pod or "post_start_command" in pod
|
|
return result
|
|
|
|
|
|
def get_internal_compose_file_dir():
|
|
# TODO: refactor to use common code with deploy command
|
|
# See:
|
|
# https://stackoverflow.com/questions/25389095/python-get-path-of-root-project-structure
|
|
data_dir = Path(__file__).absolute().parent.joinpath("data")
|
|
source_compose_dir = data_dir.joinpath("compose")
|
|
return source_compose_dir
|
|
|
|
|
|
def get_internal_config_dir():
|
|
# TODO: refactor to use common code with deploy command
|
|
data_dir = Path(__file__).absolute().parent.joinpath("data")
|
|
source_config_dir = data_dir.joinpath("config")
|
|
return source_config_dir
|
|
|
|
|
|
def get_k8s_dir():
|
|
data_dir = Path(__file__).absolute().parent.joinpath("data")
|
|
source_config_dir = data_dir.joinpath("k8s")
|
|
return source_config_dir
|
|
|
|
|
|
def get_parsed_deployment_spec(spec_file):
|
|
spec_file_path = Path(spec_file)
|
|
try:
|
|
return get_yaml().load(open(spec_file_path, "r"))
|
|
except FileNotFoundError as error:
|
|
# We try here to generate a useful diagnostic error
|
|
print(f"Error: spec file: {spec_file_path} does not exist")
|
|
print(f"Exiting, error: {error}")
|
|
sys.exit(1)
|
|
|
|
|
|
def stack_is_external(stack: str):
|
|
# Bit of a hack: if the supplied stack string represents
|
|
# a path that exists then we assume it must be external
|
|
return Path(stack).exists() if stack is not None else False
|
|
|
|
|
|
def stack_is_in_deployment(stack: Path):
|
|
if isinstance(stack, os.PathLike):
|
|
return stack.joinpath(deployment_file_name).exists()
|
|
else:
|
|
return False
|
|
|
|
|
|
def get_yaml():
|
|
# See: https://stackoverflow.com/a/45701840/1701505
|
|
yaml = ruamel.yaml.YAML()
|
|
yaml.preserve_quotes = True
|
|
yaml.indent(sequence=3, offset=1)
|
|
return yaml
|
|
|
|
|
|
# TODO: this is fragile wrt to the subcommand depth
|
|
# See also: https://github.com/pallets/click/issues/108
|
|
def global_options(ctx):
|
|
return ctx.parent.parent.obj
|
|
|
|
|
|
# TODO: hack
|
|
def global_options2(ctx):
|
|
return ctx.parent.obj
|
|
|
|
|
|
def error_exit(s) -> NoReturn:
|
|
print(f"ERROR: {s}")
|
|
sys.exit(1)
|
|
|
|
|
|
def warn_exit(s) -> NoReturn:
|
|
print(f"WARN: {s}")
|
|
sys.exit(0)
|
|
|
|
|
|
def env_var_map_from_file(file: Path) -> Mapping[str, Optional[str]]:
|
|
return dotenv_values(file)
|