Add Debian-specific OSBuild stages: apt, debootstrap, ostree-commit
Some checks are pending
Checks / Spelling (push) Waiting to run
Checks / Python Linters (push) Waiting to run
Checks / Shell Linters (push) Waiting to run
Checks / 📦 Packit config lint (push) Waiting to run
Checks / 🔍 Check for valid snapshot urls (push) Waiting to run
Checks / 🔍 Check JSON files for formatting consistency (push) Waiting to run
Generate / Documentation (push) Waiting to run
Generate / Test Data (push) Waiting to run
Tests / Unittest (push) Waiting to run
Tests / Assembler test (legacy) (push) Waiting to run
Tests / Smoke run: unittest as normal user on default runner (push) Waiting to run

This commit is contained in:
robojerk 2025-08-22 18:11:39 -07:00
parent 3e37ad3b92
commit 31162116f8
7 changed files with 465 additions and 105 deletions

2
.gitignore vendored
View file

@ -25,3 +25,5 @@ venv
/.tox /.tox
/test/data/certs/lib.sh /test/data/certs/lib.sh
debian-forge

82
stages/org.osbuild.apt Normal file
View file

@ -0,0 +1,82 @@
#!/usr/bin/python3
import os
import sys
import subprocess
import json
import osbuild.api
def run_apt_command(tree, command, env=None):
"""Run apt command in the target filesystem"""
if env is None:
env = {}
# Set up environment for non-interactive operation
apt_env = {
"DEBIAN_FRONTEND": "noninteractive",
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
}
apt_env.update(env)
# Run command in chroot
cmd = ["chroot", tree] + command
result = subprocess.run(cmd, env=apt_env, capture_output=True, text=True)
if result.returncode != 0:
print(f"Error running apt command: {command}")
print(f"stdout: {result.stdout}")
print(f"stderr: {result.stderr}")
return False
return True
def main(tree, options):
"""Main function for apt stage"""
# Get options
packages = options.get("packages", [])
recommends = options.get("recommends", False)
unauthenticated = options.get("unauthenticated", False)
update = options.get("update", True)
if not packages:
print("No packages specified for installation")
return 1
# Update package lists if requested
if update:
print("Updating package lists...")
if not run_apt_command(tree, ["apt-get", "update"]):
return 1
# Build apt-get install command
apt_options = ["apt-get", "-y"]
if not recommends:
apt_options.append("--no-install-recommends")
if unauthenticated:
apt_options.append("--allow-unauthenticated")
apt_options.extend(["install"] + packages)
# Install packages
print(f"Installing packages: {', '.join(packages)}")
if not run_apt_command(tree, apt_options):
return 1
# Clean up package cache
print("Cleaning package cache...")
if not run_apt_command(tree, ["apt-get", "clean"]):
return 1
print("Package installation completed successfully")
return 0
if __name__ == '__main__':
args = osbuild.api.arguments()
r = main(args["tree"], args["options"])
sys.exit(r)

View file

@ -0,0 +1,52 @@
{
"name": "org.osbuild.apt",
"version": "1",
"description": "Install Debian packages using apt",
"options": {
"type": "object",
"properties": {
"packages": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of packages to install"
},
"recommends": {
"type": "boolean",
"default": false,
"description": "Install recommended packages"
},
"unauthenticated": {
"type": "boolean",
"default": false,
"description": "Allow unauthenticated packages"
},
"update": {
"type": "boolean",
"default": true,
"description": "Update package lists before installation"
}
},
"required": ["packages"]
},
"inputs": {
"type": "object",
"additionalProperties": false
},
"devices": {
"type": "object",
"additionalProperties": false
},
"mounts": {
"type": "object",
"additionalProperties": false
},
"capabilities": {
"type": "array",
"items": {
"type": "string"
},
"default": ["CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_MKNOD", "CAP_SETGID", "CAP_SETUID", "CAP_SYS_CHROOT"]
}
}

View file

@ -0,0 +1,111 @@
#!/usr/bin/python3
import os
import sys
import subprocess
import tempfile
import shutil
import osbuild.api
def run_debootstrap(suite, target, mirror, arch=None, variant=None, extra_packages=None):
"""Run debootstrap to create base Debian filesystem"""
cmd = ["debootstrap"]
if arch:
cmd.extend(["--arch", arch])
if variant:
cmd.extend(["--variant", variant])
if extra_packages:
cmd.extend(["--include", ",".join(extra_packages)])
cmd.extend([suite, target, mirror])
print(f"Running debootstrap: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"Error running debootstrap:")
print(f"stdout: {result.stdout}")
print(f"stderr: {result.stderr}")
return False
return True
def setup_apt_sources(tree, suite, mirror):
"""Set up apt sources.list for the target filesystem"""
sources_content = f"""deb {mirror} {suite} main
deb {mirror} {suite}-updates main
deb {mirror} {suite}-security main
"""
sources_path = os.path.join(tree, "etc/apt/sources.list")
os.makedirs(os.path.dirname(sources_path), exist_ok=True)
with open(sources_path, "w") as f:
f.write(sources_content)
print(f"Created sources.list for {suite}")
def main(tree, options):
"""Main function for debootstrap stage"""
# Get options
suite = options.get("suite", "bookworm")
mirror = options.get("mirror", "http://deb.debian.org/debian")
arch = options.get("arch")
variant = options.get("variant", "minbase")
extra_packages = options.get("extra_packages", [])
if not suite:
print("No suite specified for debootstrap")
return 1
if not mirror:
print("No mirror specified for debootstrap")
return 1
# Create temporary directory for debootstrap
with tempfile.TemporaryDirectory() as temp_dir:
print(f"Creating base Debian filesystem in {temp_dir}")
# Run debootstrap
if not run_debootstrap(suite, temp_dir, mirror, arch, variant, extra_packages):
return 1
# Set up apt sources
setup_apt_sources(temp_dir, suite, mirror)
# Copy files to target tree
print(f"Copying filesystem to target tree: {tree}")
# Ensure target directory exists
os.makedirs(tree, exist_ok=True)
# Copy all files from temp directory to target
for item in os.listdir(temp_dir):
src = os.path.join(temp_dir, item)
dst = os.path.join(tree, item)
if os.path.isdir(src):
if os.path.exists(dst):
shutil.rmtree(dst)
shutil.copytree(src, dst)
else:
shutil.copy2(src, dst)
print("Base Debian filesystem created successfully")
return 0
if __name__ == '__main__':
args = osbuild.api.arguments()
r = main(args["tree"], args["options"])
sys.exit(r)

View file

@ -0,0 +1,57 @@
{
"name": "org.osbuild.debootstrap",
"version": "1",
"description": "Create base Debian filesystem using debootstrap",
"options": {
"type": "object",
"properties": {
"suite": {
"type": "string",
"default": "bookworm",
"description": "Debian suite to bootstrap (e.g., bookworm, sid)"
},
"mirror": {
"type": "string",
"default": "http://deb.debian.org/debian",
"description": "Debian mirror URL"
},
"arch": {
"type": "string",
"description": "Target architecture (e.g., amd64, arm64)"
},
"variant": {
"type": "string",
"default": "minbase",
"description": "Debootstrap variant (e.g., minbase, buildd)"
},
"extra_packages": {
"type": "array",
"items": {
"type": "string"
},
"default": [],
"description": "Additional packages to include in base filesystem"
}
},
"required": ["suite", "mirror"]
},
"inputs": {
"type": "object",
"additionalProperties": false
},
"devices": {
"type": "object",
"additionalProperties": false
},
"mounts": {
"type": "object",
"additionalProperties": false
},
"capabilities": {
"type": "array",
"items": {
"type": "string"
},
"default": ["CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_MKNOD", "CAP_SETGID", "CAP_SETUID"]
}
}

View file

@ -1,73 +1,122 @@
#!/usr/bin/python3 #!/usr/bin/python3
import json
import os import os
import subprocess
import sys import sys
import subprocess
import json
import tempfile import tempfile
from osbuild import api import osbuild.api
from osbuild.util import ostree
def main(inputs, output_dir, options, meta): def run_ostree_command(cmd, cwd=None, env=None):
tree = inputs["tree"]["path"] """Run ostree command and return result"""
selinux_label_version = options.get("selinux-label-version", 0) if env is None:
env = {}
result = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True)
if result.returncode != 0:
print(f"Error running ostree command: {' '.join(cmd)}")
print(f"stdout: {result.stdout}")
print(f"stderr: {result.stderr}")
return False, result.stderr
return True, result.stdout
ref = options["ref"]
os_version = options.get("os_version", None)
parent = options.get("parent", None)
# rpm-ostree compose commit wants to consume the commit def create_ostree_repo(repo_path):
# so for now, we copy the whole tree and let it have its """Create OSTree repository if it doesn't exist"""
# way if not os.path.exists(repo_path):
with tempfile.TemporaryDirectory(dir=output_dir) as root: print(f"Creating OSTree repository at {repo_path}")
subprocess.run(["cp", "--reflink=auto", "-a", success, _ = run_ostree_command(["ostree", "init", "--repo", repo_path])
f"{tree}/.", root], if not success:
check=True) return False
return True
repo = os.path.join(output_dir, "repo")
treefile = ostree.Treefile() def create_commit(repo_path, tree_path, branch, subject, metadata=None, collection_id=None, ref_binding=None):
treefile["ref"] = ref """Create OSTree commit from filesystem tree"""
if selinux_label_version != 0:
# Don't set if 0 (default), to support older rpm-ostree versions # Prepare commit command
treefile["selinux-label-version"] = selinux_label_version cmd = ["ostree", "commit", "--repo", repo_path, "--branch", branch]
if subject:
cmd.extend(["--subject", subject])
if collection_id:
cmd.extend(["--collection-id", collection_id])
if ref_binding:
for ref in ref_binding:
cmd.extend(["--ref-binding", ref])
if metadata:
for key, value in metadata.items():
cmd.extend(["--add-metadata-string", f"{key}={value}"])
# Add tree path
cmd.append(tree_path)
print(f"Creating OSTree commit: {' '.join(cmd)}")
success, output = run_ostree_command(cmd)
if not success:
return False, None
# Extract commit hash from output
commit_hash = output.strip()
print(f"Created commit: {commit_hash}")
return True, commit_hash
argv = ["rpm-ostree", "compose", "commit"]
argv += [f"--repo={repo}"]
if parent: def main(tree, options):
argv += [f"--parent={parent}"] """Main function for ostree commit stage"""
if os_version: # Get options
argv += [ repository = options.get("repository", "ostree-repo")
f"--add-metadata-string=version={os_version}", branch = options.get("branch", "debian/atomic")
] subject = options.get("subject", "Debian atomic commit")
metadata = options.get("metadata", {})
argv += [ collection_id = options.get("collection_id")
f"--add-metadata-string=rpmostree.inputhash={meta['id']}", ref_binding = options.get("ref_binding", [])
f"--write-composejson-to={output_dir}/compose.json"
] if not branch:
print("No branch specified for OSTree commit")
with treefile.as_tmp_file() as path: return 1
argv += [path, root]
# Create repository path
subprocess.run(argv, repo_path = os.path.join(tree, repository)
stdout=sys.stderr,
check=True) # Create OSTree repository
if not create_ostree_repo(repo_path):
with open(os.path.join(output_dir, "compose.json"), "r", encoding="utf8") as f: return 1
compose = json.load(f)
# Create commit
api.metadata({"compose": compose}) success, commit_hash = create_commit(
repo_path, tree, branch, subject, metadata, collection_id, ref_binding
)
if not success:
return 1
# Write commit info to output
commit_info = {
"repository": repository,
"branch": branch,
"commit": commit_hash,
"subject": subject
}
output_file = os.path.join(tree, "ostree-commit.json")
with open(output_file, "w") as f:
json.dump(commit_info, f, indent=2)
print("OSTree commit created successfully")
return 0
if __name__ == '__main__': if __name__ == '__main__':
args = api.arguments() args = osbuild.api.arguments()
r = main(args["tree"], args["options"])
r = main(args["inputs"],
args["tree"],
args["options"],
args["meta"])
sys.exit(r) sys.exit(r)

View file

@ -1,58 +1,65 @@
{ {
"summary": "Assemble a file system tree into a ostree commit", "name": "org.osbuild.ostree.commit",
"description": [ "version": "1",
"Needs a file system tree that is already conforming to the ostree", "description": "Create OSTree commit from filesystem tree",
"system layout[1], specified via the `tree` input and commits it", "options": {
"to a repository. The repository must have been created at `/repo`.", "type": "object",
"Additional metadata is stored in `/compose.json` which contains", "properties": {
"the commit compose information. This is also returned via the", "repository": {
"metadata API to osbuild.", "type": "string",
"[1] https://ostree.readthedocs.io/en/stable/manual/adapting-existing/" "default": "ostree-repo",
], "description": "OSTree repository name/path"
"capabilities": [ },
"CAP_MAC_ADMIN", "branch": {
"CAP_NET_ADMIN", "type": "string",
"CAP_SYS_PTRACE" "default": "debian/atomic",
], "description": "OSTree branch name for the commit"
"schema_2": { },
"options": { "subject": {
"additionalProperties": false, "type": "string",
"required": [ "default": "Debian atomic commit",
"ref" "description": "Commit message/subject"
], },
"properties": { "metadata": {
"ref": { "type": "object",
"description": "OStree ref to create for the commit", "additionalProperties": {
"type": "string",
"default": ""
},
"os_version": {
"description": "Set the version of the OS as commit metadata",
"type": "string" "type": "string"
}, },
"parent": { "default": {},
"description": "commit id of the parent commit", "description": "Additional metadata key-value pairs"
},
"collection_id": {
"type": "string",
"description": "Collection ID for ref binding"
},
"ref_binding": {
"type": "array",
"items": {
"type": "string" "type": "string"
}, },
"selinux-label-version": { "default": [],
"description": "Set selinux label version", "description": "List of ref bindings for the commit"
"type": "integer",
"default": 0
}
} }
}, },
"inputs": { "required": ["branch"]
"type": "object", },
"additionalProperties": false, "inputs": {
"required": [ "type": "object",
"tree" "additionalProperties": false
], },
"properties": { "devices": {
"tree": { "type": "object",
"type": "object", "additionalProperties": false
"additionalProperties": true },
} "mounts": {
} "type": "object",
} "additionalProperties": false
},
"capabilities": {
"type": "array",
"items": {
"type": "string"
},
"default": ["CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_MKNOD", "CAP_SETGID", "CAP_SETUID"]
} }
} }