diff --git a/monitoring/docker-exporter/exporter.py b/monitoring/docker-exporter/exporter.py index 908aa27..1a98d42 100644 --- a/monitoring/docker-exporter/exporter.py +++ b/monitoring/docker-exporter/exporter.py @@ -167,36 +167,53 @@ def parse_dockerfile_for_image(dockerfile_path): if not os.path.exists(dockerfile_path): return None - image_name = None - try: + arg_defaults = {} + last_from = None with open(dockerfile_path) as df: for line in df: line = line.strip() - # Prefer LABEL with image if present + if not line or line.startswith("#"): + continue + + if line.upper().startswith("ARG "): + arg_body = line[4:].strip() + if "=" in arg_body: + key, value = arg_body.split("=", 1) + arg_defaults[key.strip()] = value.strip() + continue + + # Prefer LABEL with image if present. if "LABEL" in line and "image=" in line: match = re.search(r'image=["\']?([^"\']+)["\']?', line) if match: - image_name = match.group(1) + image_name = normalize_image_name(substitute_dockerfile_args(match.group(1), arg_defaults)) logger.debug(f"Found LABEL image={image_name} in {dockerfile_path}") return image_name - # If no LABEL, use the last FROM line as fallback - df.seek(0) - last_from = None - for line in df: - line = line.strip() if line.upper().startswith("FROM "): - parts = line.split() - if len(parts) >= 2: - last_from = parts[1] - if last_from: - logger.debug(f"Found base FROM {last_from} in {dockerfile_path}") - return last_from + from_clause = line[5:].strip() + if from_clause.startswith("--"): + split_clause = from_clause.split(None, 1) + if len(split_clause) < 2: + continue + from_clause = split_clause[1] + + parts = from_clause.split() + if not parts: + continue + + candidate = substitute_dockerfile_args(parts[0], arg_defaults) + if candidate and candidate.lower() != "scratch": + last_from = normalize_image_name(candidate) + + if last_from: + logger.debug(f"Found base FROM {last_from} in {dockerfile_path}") + return last_from except Exception as e: logger.debug(f"Error reading Dockerfile {dockerfile_path}: {e}") - return image_name + return None def normalize_image_name(image_name): if not image_name: @@ -207,17 +224,54 @@ def normalize_image_name(image_name): return image_name return f"{image_name}:latest" +def is_compose_build_placeholder(image_name, project_name): + if not image_name: + return False + candidate = str(image_name) + project_prefix = f"{project_name}-" + if candidate.startswith(project_prefix): + return True + # Keep backward-compatible behavior for historical default project prefix. + return candidate.startswith("core-") + +def substitute_dockerfile_args(value, arg_defaults): + if not value: + return value + + pattern = re.compile(r"\$\{([^}]+)\}|\$([A-Za-z_][A-Za-z0-9_]*)") + + def replacer(match): + expr = match.group(1) + simple = match.group(2) + if simple: + return arg_defaults.get(simple, "") + + if ":-" in expr: + var_name, default_value = expr.split(":-", 1) + return arg_defaults.get(var_name, default_value) + if "-" in expr: + var_name, default_value = expr.split("-", 1) + return arg_defaults.get(var_name, default_value) + return arg_defaults.get(expr, "") + + return pattern.sub(replacer, value) + def expand_compose_path(path_value, project_root): raw = str(path_value) raw = raw.replace("${PROJECT_ROOT}", project_root).replace("$PROJECT_ROOT", project_root) return os.path.expandvars(raw) +def get_project_root_from_script(script_path): + if not script_path: + return os.getcwd() + return os.path.dirname(os.path.abspath(script_path)) + # --- Compose parsing --- def get_compose_files_from_script(script_path): files = [] if not os.path.exists(script_path): return files - base_dir = os.path.dirname(script_path) + base_dir = get_project_root_from_script(script_path) try: with open(script_path) as f: content = f.read() @@ -302,7 +356,11 @@ def parse_compose_services(compose_files, project_name, project_root): from_dockerfile = normalize_image_name(parse_dockerfile_for_image(dockerfile_path)) local_built_image = resolve_local_build_image(svc_name, project_name) - resolved_image = image or local_built_image or from_dockerfile or f"{project_name}-{svc_name}:latest" + placeholder_image = is_compose_build_placeholder(image, project_name) or is_compose_build_placeholder(local_built_image, project_name) + if placeholder_image: + resolved_image = from_dockerfile or image or local_built_image or f"{project_name}-{svc_name}:latest" + else: + resolved_image = image or local_built_image or from_dockerfile or f"{project_name}-{svc_name}:latest" svc_map[svc_name] = { "image": resolved_image, @@ -325,7 +383,7 @@ def check_containers(): CONTAINER_UPDATE.clear() project_name = parse_project_name_from_script(SERVICES_UP_SCRIPT) - project_root = os.path.dirname(SERVICES_UP_SCRIPT) + project_root = get_project_root_from_script(SERVICES_UP_SCRIPT) compose_files = get_compose_files_from_script(SERVICES_UP_SCRIPT) svc_map = parse_compose_services(compose_files, project_name, project_root) @@ -360,7 +418,7 @@ def check_containers(): def dump_service_image_mapping(): project_name = parse_project_name_from_script(SERVICES_UP_SCRIPT) - project_root = os.path.dirname(SERVICES_UP_SCRIPT) + project_root = get_project_root_from_script(SERVICES_UP_SCRIPT) compose_files = get_compose_files_from_script(SERVICES_UP_SCRIPT) svc_map = parse_compose_services(compose_files, project_name, project_root) mapping = {name: data["image"] for name, data in sorted(svc_map.items())}