chore: add development Docker image skill (#8477)

* chore: add development Docker image skill

Signed-off-by: WenyXu <wenymedia@gmail.com>

* fix: harden development image skill

Signed-off-by: WenyXu <wenymedia@gmail.com>

* fix: address development image review feedback

Signed-off-by: WenyXu <wenymedia@gmail.com>

* fix: add image tag script license header

Signed-off-by: WenyXu <wenymedia@gmail.com>

---------

Signed-off-by: WenyXu <wenymedia@gmail.com>
This commit is contained in:
Weny Xu
2026-07-14 11:25:37 +08:00
committed by GitHub
parent 56e9158819
commit 073ae1b184
12 changed files with 1382 additions and 0 deletions
@@ -0,0 +1,368 @@
---
name: greptimedb-development-docker-image
description: Builds a development-only GreptimeDB Docker image from a local debug binary for local-cluster testing, and optionally pushes it to a development registry. Use when the user asks to package, build, tag, publish, or cross-build a non-release GreptimeDB or GreptimeDB Enterprise image for debugging.
compatibility: Requires Docker with Buildx for cross-platform builds; Podman is supported for native-platform builds.
metadata:
protocols: docker buildx podman
platforms: linux-amd64 linux-arm64
---
# GreptimeDB Development Docker Image
## Goal
Package a locally built GreptimeDB binary into a **development-only** Docker
image for debugging and local-cluster testing, optionally push it to a
development registry, and retain the non-secret image settings in `.env` for
the next build. It is **not** a release-image workflow and must not be used to
publish a production or release artifact.
This skill follows the documented development-image build procedure:
- Build `greptime` with Cargo's `nightly` profile.
- Copy the target binary into the Docker build context as `./greptime`.
- Build the supplied `Dockerfile`, which uses Ubuntu 24.04 only as the runtime
base image and exposes the `greptime` binary as its entrypoint.
## Inputs
Collect or discover the following before any build or push:
| Input | Discovery and rule |
| --- | --- |
| Source repository | Treat the directory in which this skill is invoked as the default workspace. Include it as an editable field in the batch configuration; do not ask a separate confirmation. Do not assume open-source versus Enterprise. |
| Edition and binary | Default to `greptime`; use the binary name the user requests for Enterprise builds. The copied Docker-context filename must always be `greptime`. |
| Cargo profile and binary source | Include this in the batch configuration. Default to rebuilding `nightly`; reuse an existing binary only after the user explicitly accepts that its freshness is unverified. |
| Target platform | Preselect `linux/amd64` unless Docker's server is `linux/arm64`, then preselect `linux/arm64`. Let the user override it in the batch configuration. Ubuntu 24.04 is the runtime base image, not a target-platform choice. Each image has exactly one target platform, but it may differ from the host platform. |
| Build mode | Ask whether the user wants a locally loadable debug image or a registry push. |
| Registry/repository | Read `IMAGE_REGISTRY` and `IMAGE_REPOSITORY` only from the selected workspace's `.env` and prefill them in the batch configuration. Example: `registry.example.com/team` + `greptimedb-dev`. |
| Tag | Read `IMAGE_TAG` only from the selected workspace's `.env` and prefill it in the batch configuration. When the tag exists in the selected registry, preselect an incremented version. |
Use the image reference `${IMAGE_REGISTRY}/${IMAGE_REPOSITORY}:${IMAGE_TAG}`.
If the registry is intentionally empty, omit its slash rather than producing a
leading slash.
## Interactive Workflow
Use the platform's interactive prompt component for every question, selection,
and confirmation. Do not ask an open-ended text question when a single-choice,
multi-select, or confirmation component can represent the decision. If the
platform does not provide an interactive component, use the equivalent numbered
or lettered prompt below and wait for input before continuing.
### Batch configuration
Minimize user round trips: run the collector first against the invocation
directory, then use one interactive form or batched prompt to collect workspace,
edition/binary, profile/reuse-or-rebuild choice, target platform, build mode,
registry/repository, tag, and whether to inspect the configured registry tag.
Use the invocation directory as the editable workspace default. Prefill
collector values and mark recommended defaults. If the user changes workspace,
rerun the collector for that workspace without asking another configuration
question. Only ask a follow-up if information is missing or invalid, or a safety
gate is required. Treat unchanged prefilled values as accepted.
Keep separate confirmation components only for elevated privileges: `sudo`,
package installation, and privileged QEMU setup. Use one final confirmation for
all non-privileged selected work.
Always offer **Cancel** for an action that can write state, build, push, or
require elevated privileges. Display the relevant preview before the user
confirms it: source/binary path, `file` architecture result, platform(s), image
reference, build mode, and non-secret `.env` values.
### Batch configuration fields
Use single-choice controls inside the one batch form for mutually exclusive
choices:
| Decision | Required choices |
| --- | --- |
| Cargo output | Rebuild `nightly` (recommended), reuse an existing binary only with a freshness-unverified acknowledgement, choose a profile/binary |
| Target platform | `linux/amd64` (default unless Docker server is arm64), `linux/arm64` (default when Docker server is arm64). The selected image may be cross-built with Buildx. |
| Build mode | Load locally (recommended), push to development registry |
| Registry/repository | Use `.env` defaults, enter new values |
| Tag | Use proposed increment when the configured tag exists, enter a version |
When push is selected and the image configuration is complete, automatically
run the read-only registry-tag check. It may return `unknown`; do not treat that
as a missing tag.
Use one final confirmation for all selected non-privileged operations:
- persist changed non-secret image settings to `.env`;
- run the selected Cargo build or reuse the resolved binary;
- prepare the Docker context;
- build, verify, and, when selected, push the image.
The final confirmation must clearly state: **“This creates a development and
local-cluster test image, not a release or production artifact.”**
For a real multi-select only, use lettered choices (`[A]`, `[B]`) and accept
`all`, `none`, or `cancel`; otherwise use single-choice components.
### Preflight context collection
Before asking the profile, registry, or tag questions, run the bundled,
read-only collector. It works on macOS and Linux, does not invoke `cargo build`,
and emits JSON that can be shown or summarized to the user:
```bash
python3 <skill-dir>/scripts/collect_context.py \
--source <source-checkout> \
--bin greptime \
--profile nightly \
[--target <rust-target>]
```
Use its report to identify:
- missing `IMAGE_REGISTRY`, `IMAGE_REPOSITORY`, or `IMAGE_TAG` values in the
selected workspace's `.env`;
- Cargo binary targets available in the workspace and whether the requested
`--bin` exists;
- the resolved Cargo target directory, expected profile output path, and whether
that binary already exists; and
- macOS/Linux host architecture plus Docker's server platform when Docker is
available.
When push is selected and image configuration is complete, rerun the collector
with `--check-registry-tag` and all selected image values. It uses the selected
engine's read-only manifest inspection and returns `exists` or `unknown`;
`unknown` includes an unavailable registry, an absent tag, or missing
authentication and must not be treated as an absent tag.
```bash
python3 <skill-dir>/scripts/collect_context.py \
--source <source-checkout> \
--bin <binary> \
--profile <profile> \
--engine <docker|podman> \
--registry <registry> \
--repository <repository> \
--tag <tag> \
--check-registry-tag
```
When the status is `exists` and the tag ends in a number, preselect the
collector's `candidate_tag` as the next development tag. The user must still
confirm it before `.env` is updated or an image is built. Never use automatic
incrementing as permission to overwrite or push an existing image.
If Cargo metadata or the requested binary target is unavailable, stop before
building and ask the user to correct the source path or binary target.
The profile/binary choice is a field in the batch configuration, not a separate
question. Its default is the existing `nightly` output when present:
```text
Question: Which Cargo build output should package this image?
Options:
- Rebuild nightly (Recommended): run Cargo with `--profile nightly`, then use its output.
- Reuse nightly output: use the existing output only after showing its path, modification time, size, and matching platform; freshness is unverified.
- Choose profile or binary: provide a different Cargo profile or an explicit binary path.
```
Do not infer that a binary under `target/debug`, `target/release`, or another
profile is suitable. For an existing binary, show its resolved path and run
`file` before asking for final build confirmation.
## Environment State
The image configuration lives only in the selected workspace's `.env`. The
collector reads only the following keys and never reads or displays other `.env`
values:
```dotenv
IMAGE_REGISTRY=registry.example.com/team
IMAGE_REPOSITORY=greptimedb-dev
IMAGE_TAG=dev-001
```
1. Read `.env` if it exists. Ignore blank lines and `#` comments.
2. Show the discovered registry, repository, and proposed tag to the user.
3. Include registry/repository and tag in the batch configuration even when
values already exist; saved values are defaults, not authorization to reuse
them.
4. Compare the selected configuration with the three current managed values. If
all match, skip `.env` confirmation and do not update the file. Otherwise,
preview only the changed `IMAGE_*` values, request confirmation, and update
the file while preserving unrelated entries and comments.
5. Never put registry credentials, access tokens, passwords, or `docker login`
output in `.env`, build commands, logs, or responses. Ask the user to log in
themselves if a push needs authentication.
For a repeated build, offer an incremented tag before asking. Use
`scripts/next_image_tag.py --tag <existing-tag>` to calculate it. It increments
the final numeric component while preserving zero padding:
```text
weny-2025-0715-01 -> weny-2025-0715-02
v0.1.4 -> v0.1.5
debug-009 -> debug-010
```
If the saved tag has no trailing number, ask the user for the next tag rather
than inventing a versioning convention. Do not overwrite an existing image tag
without explicit user confirmation.
Only if a managed value is missing or differs, persist the values with the
bundled helper after confirmation instead of hand-editing the file:
```bash
python3 <skill-dir>/scripts/update_image_env.py \
--env <source-checkout>/.env \
--registry <registry> \
--repository <repository> \
--tag <tag>
```
## Build Procedure
### 1. Inspect the host and container tooling
Run these checks and report the selected path:
```bash
uname -s
uname -m
docker version --format '{{.Server.Os}}/{{.Server.Arch}}'
docker buildx version
docker buildx inspect --bootstrap
```
For a native-platform build, Podman can be used if Docker is unavailable. For
any non-native request, require Docker Buildx. Do not silently fall back to a
native build when the requested image platform differs.
If the requested target is unavailable, stop and ask the user to configure a
Buildx builder and cross-compilation toolchain externally. Do not automate
privileged QEMU or binfmt setup, and never run an unpinned privileged image.
### 2. Build the executable for the image platform
From the source checkout, build the requested binary. For open-source amd64:
```bash
<skill-dir>/scripts/build_binary.sh \
--source <source-checkout> --package cmd --bin greptime --profile nightly
```
For open-source arm64:
```bash
<skill-dir>/scripts/build_binary.sh \
--source <source-checkout> \
--package cmd \
--bin greptime \
--profile nightly \
--target aarch64-unknown-linux-gnu
```
For Enterprise, use the user-provided executable target, for example:
```bash
<skill-dir>/scripts/build_binary.sh \
--source <source-checkout> --bin greptime-ent-cloud --profile nightly
```
For a user-selected profile, replace `nightly` in the helper call and resolve
the binary under `target/<profile>/<binary>` (or
`target/<rust-target>/<profile>/<binary>` for cross-compilation). Reuse the
existing target file only when the interactive profile question selected reuse;
otherwise invoke the helper to rebuild it.
Do not use a binary compiled for the host architecture in an image intended for
another architecture. Determine the binary path from the Cargo target and
profile, then copy it into the Docker context as `greptime`:
```bash
cp <source-target-binary> <build-context>/greptime
```
Verify it before building the image with `file <build-context>/greptime`; its
reported architecture must match the requested target platform.
Create a fresh isolated build context (never use the repository root) and use
the bundled preparation script. The helper rejects an existing Dockerfile or
binary output to prevent accidental context reuse:
```bash
<skill-dir>/scripts/prepare_context.sh \
--context "$(mktemp -d)" \
--binary <source-target-binary> \
--dockerfile <skill-dir>/assets/Dockerfile \
--platform <platform>
```
### 3. Build or push the image
Run the command only after the user confirms the final image reference and
whether it should be pushed.
Use the bundled build helper rather than spelling out individual Docker or
Podman commands. It follows the existing scripts' Docker-first/Podman-fallback
behavior, uses Buildx for a non-native Docker request, and accepts exactly one
target platform per image:
```bash
<skill-dir>/scripts/build_image.sh \
--context <build-context> \
--image <image-reference> \
--platform <platform> \
--mode local|push
```
`--mode local` builds a native image locally or uses `docker buildx --load` for
a non-native image. `--mode push` pushes the selected single-platform image.
The helper never pushes in `local` mode.
This is intentionally a runtime image built from a precompiled binary, not a
multi-stage Dockerfile. Ubuntu 24.04 is the runtime base image only; select one
target platform separately as `linux/amd64` or `linux/arm64`. Only introduce a
multi-stage Dockerfile if the user asks to compile within Docker or the local
Rust toolchain cannot build the required target. Keep the final Ubuntu 24.04
runtime stage and existing entrypoint unless the user asks to change runtime
behavior.
At the final confirmation, repeat that the selected reference is a
**development/local-cluster test image**, not a release artifact. If the user
requests a release or production image, stop and direct them to the release
process instead.
### 4. Verify the result
For a local image, use the selected engine to inspect its architecture and
start it with `--help`:
```bash
docker image inspect <image-reference> --format '{{.Os}}/{{.Architecture}}'
docker run --rm <image-reference> --help
```
For Podman, use `podman image inspect <image-reference>` and
`podman run --rm <image-reference> --help`. For a pushed Podman image, use
`podman manifest inspect <image-reference>`.
For a pushed image, inspect its remote manifest:
```bash
docker buildx imagetools inspect <image-reference>
```
Report the final image reference, target platform, source binary path, and
whether the image was loaded locally or pushed.
## Safety Rules
- This skill is only for development and local-cluster testing. Never present
its image as a production, release, or officially published artifact.
- Never push by default. Local debug builds should use a local tag and load the
image unless the user explicitly requests a push.
- Do not run `docker login`, privileged QEMU setup, package installation, or
overwrite a tag without confirmation.
- Do not delete existing images, builders, binaries, or `.env` entries as part
of this workflow.
- Stop and explain if Docker's server is unavailable, Buildx cannot support the
requested platform, or the copied binary architecture does not match the
image platform.
@@ -0,0 +1,14 @@
FROM ubuntu:24.04
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
curl \
unzip \
mysql-client \
wget \
&& rm -rf /var/lib/apt/lists/*
COPY ./greptime /greptime/bin/
ENV PATH=/greptime/bin/:$PATH
ENTRYPOINT ["greptime"]
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Validate that a binary is an executable ELF for a requested Linux platform."""
import argparse
import stat
import struct
import sys
from pathlib import Path
from typing import Optional
PLATFORM_MACHINES = {"linux/amd64": 0x3E, "linux/arm64": 0xB7}
def validate_platform(platform: str) -> int:
try:
return PLATFORM_MACHINES[platform]
except KeyError as error:
raise ValueError("platform must be linux/amd64 or linux/arm64") from error
def inspect_elf(path: Path) -> int:
executable_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
if path.is_symlink() or not path.is_file() or not (path.stat().st_mode & executable_bits):
raise ValueError("binary must be an executable regular file")
try:
with path.open("rb") as handle:
header = handle.read(64)
except OSError as error:
raise ValueError(f"cannot read binary: {error}") from error
if len(header) != 64 or header[:4] != b"\x7fELF":
raise ValueError("binary is not ELF")
if header[4] != 2 or header[5] != 1 or header[6] != 1:
raise ValueError("binary must be a 64-bit little-endian ELF")
if header[7] not in (0, 3):
raise ValueError("binary must use the System V or Linux ELF ABI")
elf_type, machine, version = struct.unpack_from("<HHI", header, 16)
header_size = struct.unpack_from("<H", header, 52)[0]
if elf_type not in (2, 3) or version != 1 or header_size != 64:
raise ValueError("binary has an unsupported ELF executable header")
return machine
def validate_binary(path: Path, platform: str) -> None:
expected = validate_platform(platform)
actual = inspect_elf(path)
if actual != expected:
raise ValueError("binary architecture does not match requested platform")
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--binary", required=True, type=Path)
parser.add_argument("--platform", required=True)
args = parser.parse_args(argv)
try:
validate_binary(args.binary, args.platform)
except ValueError as error:
print(f"error: {error}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
printf 'Usage: %s --source PATH --bin NAME --profile PROFILE [--target RUST_TARGET] [--package PACKAGE] [--features FEATURES]\n' "$0" >&2
}
source_dir=""
binary_name=""
profile=""
target=""
package=""
features=""
while [[ $# -gt 0 ]]; do
case "$1" in
--source|--bin|--profile|--target|--package|--features)
if [[ $# -lt 2 ]]; then
usage
exit 2
fi
case "$1" in
--source) source_dir="$2" ;;
--bin) binary_name="$2" ;;
--profile) profile="$2" ;;
--target) target="$2" ;;
--package) package="$2" ;;
--features) features="$2" ;;
esac
shift 2
;;
*)
usage
exit 2
;;
esac
done
if [[ -z "$source_dir" || -z "$binary_name" || -z "$profile" ]]; then
usage
exit 2
fi
if [[ ! -f "$source_dir/Cargo.toml" ]]; then
printf 'error: not a Cargo workspace: %s\n' "$source_dir" >&2
exit 1
fi
command=(cargo build --locked --bin "$binary_name" --profile "$profile")
if [[ -n "$package" ]]; then
command+=(--package "$package")
fi
if [[ -n "$features" ]]; then
command+=(--features "$features")
fi
if [[ -n "$target" ]]; then
command+=(--target "$target")
fi
printf 'building %s with profile %s\n' "$binary_name" "$profile"
(
cd "$source_dir"
exec "${command[@]}"
)
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
printf 'Usage: %s --context PATH --image REFERENCE --platform PLATFORM --mode local|push [--dockerfile PATH] [--engine auto|docker|podman] [--allow-local-tag-overwrite]\n' "$0" >&2
}
context=""
image=""
platform=""
mode=""
dockerfile=""
engine="auto"
allow_local_tag_overwrite=false
while [[ $# -gt 0 ]]; do
case "$1" in
--context|--image|--platform|--mode|--dockerfile|--engine)
if [[ $# -lt 2 ]]; then
usage
exit 2
fi
case "$1" in
--context) context="$2" ;;
--image) image="$2" ;;
--platform) platform="$2" ;;
--mode) mode="$2" ;;
--dockerfile) dockerfile="$2" ;;
--engine) engine="$2" ;;
esac
shift 2
;;
--allow-local-tag-overwrite)
allow_local_tag_overwrite=true
shift
;;
*)
usage
exit 2
;;
esac
done
if [[ -z "$context" || -z "$image" || -z "$platform" || -z "$mode" ]]; then
usage
exit 2
fi
if [[ "$mode" != "local" && "$mode" != "push" ]]; then
printf 'error: --mode must be local or push\n' >&2
exit 2
fi
if [[ "$engine" != "auto" && "$engine" != "docker" && "$engine" != "podman" ]]; then
printf 'error: --engine must be auto, docker, or podman\n' >&2
exit 2
fi
dockerfile="${dockerfile:-$context/Dockerfile}"
if [[ ! -f "$dockerfile" ]]; then
printf 'error: Dockerfile does not exist: %s\n' "$dockerfile" >&2
exit 1
fi
if [[ "$platform" == *,* ]]; then
printf 'error: only one target platform is supported per image build\n' >&2
exit 2
fi
case "$platform" in
linux/amd64|linux/arm64) ;;
*)
printf 'error: unsupported platform: %s\n' "$platform" >&2
exit 2
;;
esac
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
python3 "$script_dir/binary_platform.py" --binary "$context/greptime" --platform "$platform"
check_collision() {
local runtime="$1"
if [[ "$allow_local_tag_overwrite" == true ]]; then
return 0
fi
if [[ "$runtime" == docker ]] && docker image inspect "$image" >/dev/null 2>&1; then
printf 'error: local image tag exists: %s (pass --allow-local-tag-overwrite to replace it)\n' "$image" >&2
exit 3
fi
if [[ "$runtime" == podman ]] && podman image exists "$image"; then
printf 'error: local image tag exists: %s (pass --allow-local-tag-overwrite to replace it)\n' "$image" >&2
exit 3
fi
}
if [[ "$engine" != podman ]] && command -v docker >/dev/null 2>&1 && server_platform="$(docker version --format '{{.Server.Os}}/{{.Server.Arch}}' 2>/dev/null)"; then
if [[ "$platform" == "$server_platform" ]]; then
check_collision docker
docker build --platform "$platform" -f "$dockerfile" -t "$image" "$context"
if [[ "$mode" == "push" ]]; then
exec docker push "$image"
fi
exit 0
fi
if [[ "$mode" == "push" ]]; then
exec docker buildx build --platform "$platform" --push -f "$dockerfile" -t "$image" "$context"
fi
check_collision docker
exec docker buildx build --platform "$platform" --load -f "$dockerfile" -t "$image" "$context"
fi
if [[ "$engine" != docker ]] && command -v podman >/dev/null 2>&1; then
native_platform="$(podman info --format '{{.Host.Os}}/{{.Host.Arch}}' 2>/dev/null || true)"
case "$native_platform" in
linux/amd64|linux/arm64) ;;
*)
printf 'error: Podman is unavailable or has unsupported platform: %s\n' "$native_platform" >&2
exit 1
;;
esac
if [[ "$platform" != "$native_platform" ]]; then
printf 'error: Docker Buildx is required for non-native platform %s\n' "$platform" >&2
exit 1
fi
check_collision podman
podman build -f "$dockerfile" -t "$image" "$context"
if [[ "$mode" == "push" ]]; then
exec podman push "$image"
fi
exit 0
fi
printf 'error: Docker or Podman is required\n' >&2
exit 1
@@ -0,0 +1,200 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Collect read-only build context for a GreptimeDB development image."""
import argparse
import json
import os
import platform
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional, cast
from next_image_tag import increment_tag
from binary_platform import PLATFORM_MACHINES, inspect_elf
from image_config import image_reference, parse_env_file, validate_values
ENV_KEYS = ("IMAGE_REGISTRY", "IMAGE_REPOSITORY", "IMAGE_TAG")
PROFILE_DIRECTORIES = {"dev": "debug", "test": "debug", "bench": "release"}
PLATFORM_ARCHITECTURES = {
"x86_64": "linux/amd64",
"amd64": "linux/amd64",
"aarch64": "linux/arm64",
"arm64": "linux/arm64",
}
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--bin", dest="binary", default="greptime")
parser.add_argument("--profile", default="nightly")
parser.add_argument("--target", help="Rust target triple for cross-compilation")
parser.add_argument("--package", help="Cargo package containing the selected binary")
parser.add_argument("--platform", choices=("linux/amd64", "linux/arm64"))
parser.add_argument("--engine", choices=("auto", "docker", "podman"), default="auto")
parser.add_argument("--registry")
parser.add_argument("--repository")
parser.add_argument("--tag")
parser.add_argument(
"--check-registry-tag",
action="store_true",
help="inspect the configured image tag with Docker without modifying it",
)
return parser.parse_args()
def native_platform(architecture: str) -> Optional[str]:
return PLATFORM_ARCHITECTURES.get(architecture.lower())
def parse_env(env_path: Path) -> dict[str, object]:
values = parse_env_file(env_path)
missing = [key for key in ("IMAGE_REPOSITORY", "IMAGE_TAG") if not values.get(key)]
try:
if not missing:
validate_values(values)
error = None
except ValueError as exc:
error = str(exc)
return {"path": str(env_path), "exists": env_path.is_file(), "values": values, "missing": missing, "error": error}
def cargo_metadata(source: Path) -> dict[str, Any]:
command = ["cargo", "metadata", "--locked", "--format-version=1", "--no-deps"]
try:
result = subprocess.run(command, cwd=source, text=True, capture_output=True, check=True)
except FileNotFoundError as error:
raise RuntimeError("cargo is not installed or is not on PATH") from error
except subprocess.CalledProcessError as error:
detail = error.stderr.strip() or error.stdout.strip()
raise RuntimeError(f"cargo metadata failed: {detail}") from error
return json.loads(result.stdout)
def binary_targets(metadata: dict[str, Any]) -> list[dict[str, Any]]:
targets = []
for package in metadata["packages"]:
for target in package["targets"]:
if "bin" in target["kind"]:
targets.append({
"package": package["name"],
"name": target["name"],
"path": target["src_path"],
"required_features": target.get("required-features", []),
})
return sorted(targets, key=lambda target: (target["name"], target["package"]))
def expected_binary(target_dir: Path, binary: str, profile: str, rust_target: Optional[str]) -> Path:
profile_directory = PROFILE_DIRECTORIES.get(profile, profile)
output_dir = target_dir / rust_target if rust_target else target_dir
return output_dir / profile_directory / binary
def engine_status(engine: str) -> dict[str, object]:
if engine == "docker":
command = ["docker", "version", "--format", "{{.Server.Os}}/{{.Server.Arch}}"]
else:
command = ["podman", "info", "--format", "{{.Host.Os}}/{{.Host.Arch}}"]
try:
result = subprocess.run(command, text=True, capture_output=True, check=True)
except (FileNotFoundError, subprocess.CalledProcessError):
return {"available": False, "server_platform": None}
return {"available": True, "server_platform": result.stdout.strip()}
def registry_tag_status(values: dict[str, str], enabled: bool, engine: str, source: str) -> dict[str, object]:
if not enabled:
return {"checked": False, "status": "not_checked", "candidate_tag": None}
try:
reference = image_reference(values)
except ValueError:
return {"checked": False, "status": "not_configured", "candidate_tag": None, "configuration_source": source}
command = ["docker", "buildx", "imagetools", "inspect", reference] if engine == "docker" else ["podman", "manifest", "inspect", reference]
try:
subprocess.run(command, text=True, capture_output=True, check=True)
except FileNotFoundError:
return {"checked": True, "status": "unknown", "candidate_tag": None, "reference": reference, "engine": engine, "configuration_source": source}
except subprocess.CalledProcessError:
return {"checked": True, "status": "unknown", "candidate_tag": None, "reference": reference, "engine": engine, "configuration_source": source}
try:
candidate_tag = increment_tag(values["IMAGE_TAG"])
except ValueError:
candidate_tag = None
return {"checked": True, "status": "exists", "candidate_tag": candidate_tag, "reference": reference, "engine": engine, "configuration_source": source}
def collect_report(args: argparse.Namespace) -> dict[str, object]:
source = args.source.expanduser().resolve()
if not (source / "Cargo.toml").is_file():
raise RuntimeError(f"not a Cargo workspace: {source}")
metadata = cargo_metadata(source)
targets = binary_targets(metadata)
target_dir = Path(metadata["target_directory"])
expected = expected_binary(target_dir, args.binary, args.profile, args.target)
host_architecture = platform.machine()
selected_target = next((target for target in targets if target["name"] == args.binary), None)
environment = parse_env(source / ".env")
supplied_values = (args.registry, args.repository, args.tag)
if any(value is not None for value in supplied_values) and not all(value is not None for value in supplied_values):
raise RuntimeError("--registry, --repository, and --tag must be supplied together")
values: dict[str, str] = {"IMAGE_REGISTRY": args.registry or "", "IMAGE_REPOSITORY": args.repository or "", "IMAGE_TAG": args.tag or ""} if all(value is not None for value in supplied_values) else cast(dict[str, str], environment["values"])
configuration_source = "arguments" if all(value is not None for value in supplied_values) else "environment"
docker = engine_status("docker")
podman = engine_status("podman")
engine = args.engine if args.engine != "auto" else "docker" if docker["available"] else "podman"
binary_details: dict[str, object] = {"platform": None, "error": None}
if expected.is_file():
try:
machine = inspect_elf(expected)
binary_details["platform"] = next(
(name for name, expected_machine in PLATFORM_MACHINES.items() if expected_machine == machine),
None,
)
if binary_details["platform"] is None:
binary_details["error"] = f"binary has unsupported ELF machine: {machine}"
except ValueError as exc:
binary_details["error"] = str(exc)
return {
"host": {"os": platform.system(), "arch": host_architecture, "native_platform": native_platform(host_architecture)},
"docker": docker,
"podman": podman,
"environment": environment,
"registry_tag": registry_tag_status(values, args.check_registry_tag, engine, configuration_source),
"cargo": {"workspace_root": metadata["workspace_root"], "target_dir": str(target_dir), "binaries": targets},
"selected_binary": {"name": args.binary, "package": args.package, "cargo_target_exists": selected_target is not None, "target": selected_target},
"expected_binary": {"profile": args.profile, "rust_target": args.target, "path": str(expected), "exists": expected.is_file(), "executable": os.access(expected, os.X_OK), "platform": binary_details["platform"], "requested_platform": args.platform, "platform_matches": binary_details["platform"] == args.platform if args.platform and binary_details["platform"] else None, "validation_error": binary_details["error"], "mtime": datetime.fromtimestamp(expected.stat().st_mtime, timezone.utc).isoformat() if expected.exists() else None, "freshness": "unverified"},
}
def main() -> int:
try:
print(json.dumps(collect_report(parse_arguments()), indent=2, sort_keys=True))
except RuntimeError as error:
print(f"error: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Bounded parsing and safe updating of the image settings in a .env file."""
import os
import re
import tempfile
from pathlib import Path
from typing import Mapping
MANAGED_KEYS = ("IMAGE_REGISTRY", "IMAGE_REPOSITORY", "IMAGE_TAG")
_REGISTRY = re.compile(r"[a-z0-9][a-z0-9.-]*(?::[0-9]+)?(?:/[a-z0-9][a-z0-9._-]*)*")
_REPOSITORY = re.compile(r"[a-z0-9][a-z0-9._-]*(?:/[a-z0-9][a-z0-9._-]*)*")
_TAG = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}")
def _value(text: str) -> str:
text = text.strip()
if not text:
return ""
quote = text[0] if text[0] in "'\"" else None
if quote:
end = text.find(quote, 1)
if end < 0 or text[end + 1 :].strip() and not text[end + 1 :].lstrip().startswith("#"):
raise ValueError("unmatched quote or trailing value syntax")
return text[1:end]
# A comment is inline only when introduced by whitespace. This keeps
# valid values such as an image tag containing '#'.
match = re.search(r"\s+#", text)
if match:
text = text[: match.start()].rstrip()
if any(character in text for character in "'\";$`"):
raise ValueError("unsupported shell syntax in .env value")
return text
def parse_env(text: str) -> dict[str, str]:
"""Parse the three managed keys, without validating their values."""
lines = text.splitlines()
result: dict[str, str] = {}
for number, original in enumerate(lines, 1):
line = original.strip()
if not line or line.startswith("#"):
continue
export = re.match(r"export[ \t]+", line)
if export:
line = line[export.end() :].lstrip()
key, separator, raw = line.partition("=")
if not separator:
continue
key = key.strip()
if key in MANAGED_KEYS:
try:
result[key] = _value(raw)
except ValueError as error:
raise ValueError(f"line {number}: {error}") from error
return result
def parse_env_file(path: Path) -> dict[str, str]:
return parse_env(path.read_text() if path.exists() else "")
def normalize_values(values: Mapping[str, str]) -> dict[str, str]:
return {
"IMAGE_REGISTRY": values.get("IMAGE_REGISTRY", ""),
"IMAGE_REPOSITORY": values.get("IMAGE_REPOSITORY", ""),
"IMAGE_TAG": values.get("IMAGE_TAG", ""),
}
def validate_values(values: Mapping[str, str]) -> None:
values = normalize_values(values)
missing = [key for key in ("IMAGE_REPOSITORY", "IMAGE_TAG") if not values.get(key)]
if missing:
raise ValueError("missing managed values: " + ", ".join(missing))
registry = values.get("IMAGE_REGISTRY", "")
if registry and (
not _REGISTRY.fullmatch(registry)
):
raise ValueError("invalid registry")
if registry:
host = registry.split("/", 1)[0]
if host != "localhost" and "." not in host and ":" not in host:
raise ValueError("ambiguous registry; use an FQDN, explicit port, or localhost")
if not _REPOSITORY.fullmatch(values["IMAGE_REPOSITORY"]):
raise ValueError("invalid repository")
if not _TAG.fullmatch(values["IMAGE_TAG"]):
raise ValueError("invalid tag")
if any(any(character.isspace() for character in value) for value in values.values()):
raise ValueError("managed values cannot contain whitespace")
def image_reference(values: Mapping[str, str]) -> str:
values = normalize_values(values)
validate_values(values)
prefix = values.get("IMAGE_REGISTRY", "")
repository = values["IMAGE_REPOSITORY"]
return f"{prefix}/{repository}:{values['IMAGE_TAG']}" if prefix else f"{repository}:{values['IMAGE_TAG']}"
def update_env(path: Path, values: Mapping[str, str]) -> bool:
"""Atomically update managed entries; return whether the file changed."""
values = normalize_values(values)
validate_values(values)
if path.is_symlink():
raise ValueError("refusing to update symlink .env")
old = path.read_text() if path.exists() else ""
parse_env(old) # detect malformed managed syntax before changing anything
lines = old.splitlines()
output: list[str] = []
written: set[str] = set()
for line in lines:
stripped = line.strip()
export = re.match(r"export[ \t]+", stripped)
candidate = stripped[export.end() :].lstrip() if export else stripped
key = candidate.split("=", 1)[0].strip() if "=" in candidate else ""
if key in MANAGED_KEYS:
if key in written:
continue
output.append(f"{key}={values[key]}")
written.add(key)
else:
output.append(line)
output.extend(f"{key}={values.get(key, '')}" for key in MANAGED_KEYS if key not in written)
new = "\n".join(output) + "\n"
if old == new:
return False
path.parent.mkdir(parents=True, exist_ok=True)
mode = os.stat(path).st_mode & 0o777 if path.exists() else 0o600
fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
try:
os.fchmod(fd, mode)
with os.fdopen(fd, "w") as handle:
handle.write(new)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
return True
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Print a tag with its final numeric component incremented."""
import argparse
import re
import sys
def increment_tag(tag: str) -> str:
match = re.search(r"(\d+)$", tag)
if match is None:
raise ValueError("tag must end with a numeric version component")
number = match.group(1)
return f"{tag[:match.start()]}{int(number) + 1:0{len(number)}d}"
def main() -> int:
parser = argparse.ArgumentParser(
description="Increment the final numeric component of a Docker image tag."
)
parser.add_argument("--tag", required=True, help="existing Docker image tag")
args = parser.parse_args()
try:
print(increment_tag(args.tag))
except ValueError as error:
print(f"error: {error}: {args.tag!r}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
printf 'Usage: %s --context PATH --binary PATH --dockerfile PATH --platform PLATFORM\n' "$0" >&2
}
context=""
binary=""
dockerfile=""
platform=""
while [[ $# -gt 0 ]]; do
case "$1" in
--context)
context="$2"
shift 2
;;
--binary)
binary="$2"
shift 2
;;
--dockerfile)
dockerfile="$2"
shift 2
;;
--platform)
platform="$2"
shift 2
;;
*)
usage
exit 2
;;
esac
done
if [[ -z "$context" || -z "$binary" || -z "$dockerfile" || -z "$platform" ]]; then
usage
exit 2
fi
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
python3 "$script_dir/binary_platform.py" --binary "$binary" --platform "$platform"
if [[ ! -f "$dockerfile" ]]; then
printf 'error: Dockerfile template does not exist: %s\n' "$dockerfile" >&2
exit 1
fi
if [[ -e "$context" && ( -L "$context" || -n "$(ls -A "$context")" ) ]]; then
printf 'error: build context must be a new or empty directory: %s\n' "$context" >&2
exit 1
fi
mkdir -p "$context"
if [[ -e "$context/greptime" || -e "$context/Dockerfile" || -L "$context/greptime" || -L "$context/Dockerfile" ]]; then
printf 'error: build context already contains output files: %s\n' "$context" >&2
exit 1
fi
cp "$binary" "$context/greptime"
cp "$dockerfile" "$context/Dockerfile"
printf 'created Dockerfile: %s\n' "$context/Dockerfile"
file "$context/greptime"
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Create or update non-secret GreptimeDB image settings in a .env file."""
import argparse
import sys
from pathlib import Path
from image_config import image_reference, normalize_values, update_env, validate_values
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--env", type=Path)
parser.add_argument("--registry", required=True)
parser.add_argument("--repository", required=True)
parser.add_argument("--tag", required=True)
parser.add_argument("--validate-only", action="store_true")
args = parser.parse_args()
try:
values = normalize_values({"IMAGE_REGISTRY": args.registry, "IMAGE_REPOSITORY": args.repository, "IMAGE_TAG": args.tag})
validate_values(values)
except ValueError as error:
print(f"error: {error}", file=sys.stderr)
return 2
if args.validate_only:
print(image_reference(values))
return 0
if args.env is None:
parser.error("--env is required unless --validate-only is set")
if update_env(args.env, values):
print(f"updated {args.env}")
else:
print(f"unchanged {args.env}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import importlib
import struct
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
binary_platform = importlib.import_module("binary_platform")
def elf(machine: int, *, bits: int = 2, endian: int = 1, abi: int = 3, elf_type: int = 3) -> bytes:
header = bytearray(64)
header[:4] = b"\x7fELF"
header[4:8] = bytes((bits, endian, 1, abi))
struct.pack_into("<H", header, 16, elf_type)
struct.pack_into("<H" if endian == 1 else ">H", header, 18, machine)
struct.pack_into("<I", header, 20, 1)
struct.pack_into("<H", header, 52, 64)
return bytes(header)
class BinaryPlatformTest(unittest.TestCase):
def make_binary(self, content: bytes) -> Path:
path = Path(self.directory.name) / "greptime"
path.write_bytes(content)
path.chmod(0o755)
return path
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
def tearDown(self):
self.directory.cleanup()
def test_matching_architectures(self):
binary_platform.validate_binary(self.make_binary(elf(0x3E)), "linux/amd64")
binary_platform.validate_binary(self.make_binary(elf(0xB7)), "linux/arm64")
def test_rejects_bad_executable_elf_and_platform(self):
path = self.make_binary(elf(0x3E))
path.chmod(0o644)
with self.assertRaises(ValueError):
binary_platform.validate_binary(path, "linux/amd64")
path = self.make_binary(b"not an elf")
with self.assertRaises(ValueError):
binary_platform.validate_binary(path, "linux/amd64")
path = self.make_binary(elf(0x3E, bits=1))
with self.assertRaises(ValueError):
binary_platform.validate_binary(path, "linux/amd64")
with self.assertRaises(ValueError):
binary_platform.validate_binary(self.make_binary(elf(0x3E, abi=9)), "linux/amd64")
with self.assertRaises(ValueError):
binary_platform.validate_binary(self.make_binary(elf(0x3E, elf_type=1)), "linux/amd64")
with self.assertRaises(ValueError):
binary_platform.validate_binary(self.make_binary(b"\x7fELF"), "linux/amd64")
with self.assertRaises(ValueError):
binary_platform.validate_binary(self.make_binary(elf(0x3E)), "darwin/amd64")
def test_rejects_wrong_architecture_and_symlink(self):
with self.assertRaises(ValueError):
binary_platform.validate_binary(self.make_binary(elf(0x3E)), "linux/arm64")
target = self.make_binary(elf(0x3E))
link = Path(self.directory.name) / "link"
link.symlink_to(target)
with self.assertRaises(ValueError):
binary_platform.validate_binary(link, "linux/amd64")
def test_cli_success_and_failures(self):
path = self.make_binary(elf(0x3E))
assert binary_platform.__file__ is not None
script = Path(binary_platform.__file__)
ok = subprocess.run([sys.executable, str(script), "--binary", str(path), "--platform", "linux/amd64"], capture_output=True, text=True)
self.assertEqual(ok.returncode, 0, ok.stderr)
bad = subprocess.run([sys.executable, str(script), "--binary", str(path), "--platform", "linux/arm64"], capture_output=True, text=True)
self.assertEqual(bad.returncode, 2)
missing = subprocess.run([sys.executable, str(script), "--binary", str(path) + ".missing", "--platform", "linux/amd64"], capture_output=True, text=True)
self.assertEqual(missing.returncode, 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import importlib
import stat
import sys
import tempfile
import unittest
from unittest import mock
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
sys.path.insert(0, str(SCRIPTS))
image_config = importlib.import_module("image_config")
VALUES = {"IMAGE_REGISTRY": "registry.example.com/team", "IMAGE_REPOSITORY": "greptime-dev", "IMAGE_TAG": "dev-001"}
class ImageConfigTest(unittest.TestCase):
def test_parse_supported_bounded_dotenv(self):
text = """\n# comment\n export IMAGE_REGISTRY = \"registry.example.com/team\" # note\nIMAGE_REPOSITORY='greptime-dev'\nIMAGE_TAG=dev-001 # safe comment\nOTHER=$HOME\n"""
self.assertEqual(image_config.parse_env(text), VALUES)
def test_parser_does_not_validate_values(self):
self.assertEqual(image_config.parse_env("IMAGE_TAG=not valid\n"), {"IMAGE_TAG": "not valid"})
def test_parser_rejects_unmatched_and_unsupported_syntax(self):
with self.assertRaises(ValueError):
image_config.parse_env('IMAGE_TAG="unterminated\n')
with self.assertRaises(ValueError):
image_config.parse_env("IMAGE_TAG=$(date)\n")
def test_validation_and_reference(self):
image_config.validate_values(VALUES)
self.assertEqual(image_config.image_reference(VALUES), "registry.example.com/team/greptime-dev:dev-001")
local = dict(VALUES, IMAGE_REGISTRY="")
self.assertEqual(image_config.image_reference(local), "greptime-dev:dev-001")
image_config.validate_values(dict(VALUES, IMAGE_REGISTRY="localhost"))
image_config.validate_values(dict(VALUES, IMAGE_REGISTRY="example.com"))
with self.assertRaises(ValueError):
image_config.validate_values(dict(VALUES, IMAGE_REGISTRY="registry/team"))
def test_atomic_update_preserves_mode_and_canonicalizes_duplicates(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / ".env"
path.write_text("# keep\nIMAGE_TAG=old\nIMAGE_TAG=duplicate\nOTHER=value\n")
path.chmod(0o640)
self.assertTrue(image_config.update_env(path, VALUES))
self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o640)
self.assertEqual(path.read_text(), "# keep\nIMAGE_TAG=dev-001\nOTHER=value\nIMAGE_REGISTRY=registry.example.com/team\nIMAGE_REPOSITORY=greptime-dev\n")
self.assertFalse(image_config.update_env(path, VALUES))
def test_update_rejects_symlink(self):
with tempfile.TemporaryDirectory() as directory:
target = Path(directory) / "target"
target.write_text("IMAGE_TAG=old\n")
link = Path(directory) / ".env"
link.symlink_to(target)
with self.assertRaises(ValueError):
image_config.update_env(link, VALUES)
self.assertEqual(target.read_text(), "IMAGE_TAG=old\n")
def test_update_clears_omitted_registry_and_uses_private_new_file_mode(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / ".env"
path.write_text("IMAGE_REGISTRY=registry.example.com\nIMAGE_REPOSITORY=old\nIMAGE_TAG=old\n")
values = {"IMAGE_REPOSITORY": "greptime-dev", "IMAGE_TAG": "dev-001"}
image_config.update_env(path, values)
self.assertIn("IMAGE_REGISTRY=\n", path.read_text())
new_path = Path(directory) / "new.env"
image_config.update_env(new_path, VALUES)
self.assertEqual(stat.S_IMODE(new_path.stat().st_mode), 0o600)
def test_failed_replace_keeps_original_and_removes_temporary_file(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / ".env"
path.write_text("OTHER=value\n")
with mock.patch("image_config.os.replace", side_effect=OSError("no space")):
with self.assertRaises(OSError):
image_config.update_env(path, VALUES)
self.assertEqual(path.read_text(), "OTHER=value\n")
self.assertEqual(list(Path(directory).glob("..env.*")), [])
if __name__ == "__main__":
unittest.main()