From 790046e4370570380ce7789f5c3733a8ef228ee9 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 11 Jun 2026 20:46:08 -0400 Subject: [PATCH 1/6] feat: add managed runtime installer contract --- README.md | 28 +- cmd/workflow-plugin-compute-container/main.go | 6 + container/managed_runtime_installer.go | 672 ++++++++++++++++++ container/managed_runtime_installer_test.go | 287 ++++++++ internal/managed_runtime_cli.go | 124 ++++ internal/managed_runtime_cli_test.go | 183 +++++ internal/plugin_test.go | 41 ++ plugin.contracts.json | 26 +- 8 files changed, 1363 insertions(+), 4 deletions(-) create mode 100644 container/managed_runtime_installer.go create mode 100644 container/managed_runtime_installer_test.go create mode 100644 internal/managed_runtime_cli.go create mode 100644 internal/managed_runtime_cli_test.go diff --git a/README.md b/README.md index cc7e6f5..aa57359 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,13 @@ host's default container namespace. Managed runtime release packaging verifies the upstream `SHA256SUMS` and `SHA256SUMS.asc` digests and emits a source manifest in the plugin release -archive. Agent-side download, extraction, signature verification, and lifecycle -wiring are intentionally left to the workflow-compute host integration phase so -the plugin does not advertise a host-installed `nerdctl` as bundled. +archive. The public `ManagedRuntimeBundleInstaller` helper and +`managed-runtime` subcommands provide the reusable agent-side lifecycle contract: +download pinned artifact/checksum/signature objects from catalog metadata, +extract the bundle into an agent-owned scoped install root, write an install +manifest, doctor the scoped install, remove only the scoped bundle root, and +reinstall from the same pinned metadata. Workflow Compute hosts consume this +contract instead of implementing private host-only bundle lifecycle behavior. The default conformance image reference is the Debian 13 distroless static index: @@ -77,3 +81,21 @@ Run installed runtime smoke probes explicitly: ```sh WORKFLOW_COMPUTE_RUNTIME_PROBE_REAL=1 GOWORK=off go test ./container -run TestDockerCompatibleRuntimeProbeRealBackends -count=1 -v ``` + +Install and verify a managed bundle from catalog metadata: + +```sh +workflow-plugin-compute-container managed-runtime install \ + --catalog managed-runtime-bundles.json \ + --install-root "$HOME/.local/share/workflow-compute/managed-runtime" \ + --bundle-id managed-containerd-linux-amd64 \ + --target-os linux \ + --target-arch amd64 + +workflow-plugin-compute-container managed-runtime doctor \ + --catalog managed-runtime-bundles.json \ + --install-root "$HOME/.local/share/workflow-compute/managed-runtime" \ + --bundle-id managed-containerd-linux-amd64 \ + --target-os linux \ + --target-arch amd64 +``` diff --git a/cmd/workflow-plugin-compute-container/main.go b/cmd/workflow-plugin-compute-container/main.go index 7d97d3c..7ba78f0 100644 --- a/cmd/workflow-plugin-compute-container/main.go +++ b/cmd/workflow-plugin-compute-container/main.go @@ -1,6 +1,9 @@ package main import ( + "context" + "os" + plugin "github.com/GoCodeAlone/workflow-plugin-compute-container/internal" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" ) @@ -8,6 +11,9 @@ import ( var version = "0.0.0" func main() { + if len(os.Args) > 1 && os.Args[1] == "managed-runtime" { + os.Exit(plugin.RunManagedRuntimeCLI(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) + } plugin.Version = version sdk.Serve(plugin.NewPlugin(), sdk.WithBuildVersion(sdk.ResolveBuildVersion(plugin.Version)), diff --git a/container/managed_runtime_installer.go b/container/managed_runtime_installer.go new file mode 100644 index 0000000..37d6e4b --- /dev/null +++ b/container/managed_runtime_installer.go @@ -0,0 +1,672 @@ +package container + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "time" + + core "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol" +) + +const managedRuntimeInstallManifestName = "wfcompute-managed-runtime-install.json" + +type ManagedRuntimeLifecycleStatus string + +const ( + ManagedRuntimeLifecycleStatusOK ManagedRuntimeLifecycleStatus = "ok" + ManagedRuntimeLifecycleStatusMissing ManagedRuntimeLifecycleStatus = "missing" + ManagedRuntimeLifecycleStatusDegraded ManagedRuntimeLifecycleStatus = "degraded" +) + +type ManagedRuntimeBundleInstaller struct { + Catalog ManagedRuntimeBundleCatalog + InstallRoot string + Source ManagedRuntimeBundleObjectSource + Now func() time.Time + HTTPClient *http.Client +} + +type ManagedRuntimeInstallRequest struct { + BundleID string + TargetOS string + TargetArch string +} + +type ManagedRuntimeDoctorRequest struct { + BundleID string + TargetOS string + TargetArch string +} + +type ManagedRuntimeUninstallRequest struct { + BundleID string +} + +type ManagedRuntimeBundleObjectRequest struct { + BundleID string + Kind string + Name string + URL string +} + +type ManagedRuntimeBundleObjectSource interface { + FetchManagedRuntimeBundleObject(context.Context, ManagedRuntimeBundleObjectRequest) ([]byte, error) +} + +type ManagedRuntimeBundleObjectSourceFunc func(context.Context, ManagedRuntimeBundleObjectRequest) ([]byte, error) + +func (f ManagedRuntimeBundleObjectSourceFunc) FetchManagedRuntimeBundleObject(ctx context.Context, request ManagedRuntimeBundleObjectRequest) ([]byte, error) { + return f(ctx, request) +} + +type HTTPManagedRuntimeBundleObjectSource struct { + Client *http.Client +} + +func (s HTTPManagedRuntimeBundleObjectSource) FetchManagedRuntimeBundleObject(ctx context.Context, request ManagedRuntimeBundleObjectRequest) ([]byte, error) { + if strings.TrimSpace(request.URL) == "" { + return nil, errors.New("managed runtime object URL is required") + } + client := s.Client + if client == nil { + client = http.DefaultClient + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, request.URL, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("fetch managed runtime object %q: %s", request.Name, resp.Status) + } + return io.ReadAll(resp.Body) +} + +type ManagedRuntimeInstallResult struct { + BundleID string `json:"bundle_id"` + Version string `json:"version"` + Root string `json:"root"` + CommandPath string `json:"command_path"` + ManifestPath string `json:"manifest_path"` + ArtifactDigest string `json:"artifact_digest"` + ChecksumDigest string `json:"checksum_digest"` + SignatureDigest string `json:"signature_digest"` + TrustRootDigest string `json:"trust_root_digest"` + ScopedStoreEnforced bool `json:"scoped_store_enforced"` + HostGlobalStoreExcluded bool `json:"host_global_store_excluded"` + InstalledAt time.Time `json:"installed_at"` +} + +type ManagedRuntimeDoctorResult struct { + Status ManagedRuntimeLifecycleStatus `json:"status"` + BundleID string `json:"bundle_id"` + Version string `json:"version,omitempty"` + Root string `json:"root,omitempty"` + CommandPath string `json:"command_path,omitempty"` + ManifestPath string `json:"manifest_path,omitempty"` + ArtifactDigest string `json:"artifact_digest,omitempty"` + SignatureDigest string `json:"signature_digest,omitempty"` + ScopedStoreEnforced bool `json:"scoped_store_enforced,omitempty"` + HostGlobalStoreExcluded bool `json:"host_global_store_excluded,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type ManagedRuntimeUninstallResult struct { + BundleID string `json:"bundle_id"` + Root string `json:"root"` + Removed bool `json:"removed"` + ScopedOnly bool `json:"scoped_only"` + ManifestPath string `json:"manifest_path"` +} + +type ManagedRuntimeReinstallResult struct { + Uninstall ManagedRuntimeUninstallResult `json:"uninstall"` + Install ManagedRuntimeInstallResult `json:"install"` + Doctor ManagedRuntimeDoctorResult `json:"doctor"` +} + +type managedRuntimeInstallManifest struct { + ProtocolVersion string `json:"protocol_version"` + BundleID string `json:"bundle_id"` + Version string `json:"version"` + OS string `json:"os"` + Arch string `json:"arch"` + Root string `json:"root"` + CommandPath string `json:"command_path"` + ArtifactName string `json:"artifact_name"` + ArtifactDigest string `json:"artifact_digest"` + ChecksumName string `json:"checksum_name"` + ChecksumDigest string `json:"checksum_digest"` + SignatureName string `json:"signature_name"` + SignatureDigest string `json:"signature_digest"` + SignatureKeyID string `json:"signature_key_id"` + TrustRootDigest string `json:"trust_root_digest"` + ScopedStoreEnforced bool `json:"scoped_store_enforced"` + HostGlobalStoreExcluded bool `json:"host_global_store_excluded"` + InstalledAt time.Time `json:"installed_at"` +} + +func (i ManagedRuntimeBundleInstaller) Install(ctx context.Context, request ManagedRuntimeInstallRequest) (ManagedRuntimeInstallResult, error) { + if err := ctx.Err(); err != nil { + return ManagedRuntimeInstallResult{}, err + } + now := i.now() + bundle, err := i.Catalog.BundleForTarget(request.BundleID, request.TargetOS, request.TargetArch, now) + if err != nil { + return ManagedRuntimeInstallResult{}, err + } + installRoot, bundleRoot, err := managedRuntimeBundleRoot(i.InstallRoot, bundle.BundleID) + if err != nil { + return ManagedRuntimeInstallResult{}, err + } + source := i.source() + artifact, checksum, signature, err := i.fetchPinnedObjects(ctx, source, bundle) + if err != nil { + return ManagedRuntimeInstallResult{}, err + } + if err := verifyManagedRuntimeChecksum(bundle, checksum); err != nil { + return ManagedRuntimeInstallResult{}, err + } + if len(signature) == 0 { + return ManagedRuntimeInstallResult{}, errors.New("managed runtime signature object is empty") + } + if err := os.MkdirAll(installRoot, 0o700); err != nil { + return ManagedRuntimeInstallResult{}, err + } + stagingParent := filepath.Join(installRoot, ".staging") + if err := os.MkdirAll(stagingParent, 0o700); err != nil { + return ManagedRuntimeInstallResult{}, err + } + stageRoot, err := os.MkdirTemp(stagingParent, bundle.BundleID+"-") + if err != nil { + return ManagedRuntimeInstallResult{}, err + } + removeStage := true + defer func() { + if removeStage { + _ = os.RemoveAll(stageRoot) + } + }() + if err := extractManagedRuntimeTarGzip(artifact, stageRoot); err != nil { + return ManagedRuntimeInstallResult{}, err + } + commandPath := filepath.Join(stageRoot, "bin", "nerdctl") + if err := managedRuntimeRequireFileUnderRoot(stageRoot, commandPath); err != nil { + return ManagedRuntimeInstallResult{}, fmt.Errorf("managed runtime command: %w", err) + } + result := ManagedRuntimeInstallResult{ + BundleID: bundle.BundleID, + Version: bundle.Version, + Root: bundleRoot, + CommandPath: filepath.Join(bundleRoot, "bin", "nerdctl"), + ManifestPath: filepath.Join(bundleRoot, managedRuntimeInstallManifestName), + ArtifactDigest: bundle.ArtifactDigest, + ChecksumDigest: bundle.ChecksumDigest, + SignatureDigest: bundle.SignatureDigest, + TrustRootDigest: bundle.TrustRootDigest, + ScopedStoreEnforced: bundle.ScopedStore.Required, + HostGlobalStoreExcluded: bundle.ScopedStore.HostGlobalVisibilityForbidden, + InstalledAt: now, + } + manifest := managedRuntimeInstallManifest{ + ProtocolVersion: core.Version, + BundleID: bundle.BundleID, + Version: bundle.Version, + OS: bundle.OS, + Arch: bundle.Arch, + Root: result.Root, + CommandPath: result.CommandPath, + ArtifactName: bundle.ArtifactName, + ArtifactDigest: bundle.ArtifactDigest, + ChecksumName: bundle.ChecksumName, + ChecksumDigest: bundle.ChecksumDigest, + SignatureName: bundle.SignatureName, + SignatureDigest: bundle.SignatureDigest, + SignatureKeyID: bundle.SignatureKeyID, + TrustRootDigest: bundle.TrustRootDigest, + ScopedStoreEnforced: result.ScopedStoreEnforced, + HostGlobalStoreExcluded: result.HostGlobalStoreExcluded, + InstalledAt: now, + } + if err := writeManagedRuntimeManifest(filepath.Join(stageRoot, managedRuntimeInstallManifestName), manifest); err != nil { + return ManagedRuntimeInstallResult{}, err + } + if err := os.RemoveAll(bundleRoot); err != nil { + return ManagedRuntimeInstallResult{}, err + } + if err := os.Rename(stageRoot, bundleRoot); err != nil { + return ManagedRuntimeInstallResult{}, err + } + removeStage = false + _ = os.RemoveAll(stagingParent) + return result, nil +} + +func (i ManagedRuntimeBundleInstaller) Doctor(ctx context.Context, request ManagedRuntimeDoctorRequest) (ManagedRuntimeDoctorResult, error) { + if err := ctx.Err(); err != nil { + return ManagedRuntimeDoctorResult{}, err + } + now := i.now() + bundle, err := i.Catalog.BundleForTarget(request.BundleID, request.TargetOS, request.TargetArch, now) + if err != nil { + return ManagedRuntimeDoctorResult{}, err + } + _, bundleRoot, err := managedRuntimeBundleRoot(i.InstallRoot, bundle.BundleID) + if err != nil { + return ManagedRuntimeDoctorResult{}, err + } + manifestPath := filepath.Join(bundleRoot, managedRuntimeInstallManifestName) + manifest, err := readManagedRuntimeManifest(manifestPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ManagedRuntimeDoctorResult{ + Status: ManagedRuntimeLifecycleStatusMissing, + BundleID: bundle.BundleID, + Root: bundleRoot, + ManifestPath: manifestPath, + Reason: "install manifest is missing", + }, nil + } + return ManagedRuntimeDoctorResult{}, err + } + result := ManagedRuntimeDoctorResult{ + Status: ManagedRuntimeLifecycleStatusOK, + BundleID: bundle.BundleID, + Version: manifest.Version, + Root: bundleRoot, + CommandPath: filepath.Join(bundleRoot, "bin", "nerdctl"), + ManifestPath: manifestPath, + ArtifactDigest: manifest.ArtifactDigest, + SignatureDigest: manifest.SignatureDigest, + ScopedStoreEnforced: manifest.ScopedStoreEnforced, + HostGlobalStoreExcluded: manifest.HostGlobalStoreExcluded, + } + if manifest.BundleID != bundle.BundleID || + manifest.ArtifactDigest != bundle.ArtifactDigest || + manifest.SignatureDigest != bundle.SignatureDigest || + manifest.TrustRootDigest != bundle.TrustRootDigest { + result.Status = ManagedRuntimeLifecycleStatusDegraded + result.Reason = "install manifest does not match pinned bundle metadata" + return result, nil + } + if manifest.CommandPath != result.CommandPath || manifest.Root != bundleRoot { + result.Status = ManagedRuntimeLifecycleStatusDegraded + result.Reason = "install manifest path scope does not match scoped root" + return result, nil + } + if !manifest.ScopedStoreEnforced || !manifest.HostGlobalStoreExcluded { + result.Status = ManagedRuntimeLifecycleStatusDegraded + result.Reason = "scoped store policy is not enforced" + return result, nil + } + if err := managedRuntimeRequireFileUnderRoot(bundleRoot, result.CommandPath); err != nil { + result.Status = ManagedRuntimeLifecycleStatusMissing + result.Reason = err.Error() + return result, nil + } + return result, nil +} + +func (i ManagedRuntimeBundleInstaller) Uninstall(ctx context.Context, request ManagedRuntimeUninstallRequest) (ManagedRuntimeUninstallResult, error) { + if err := ctx.Err(); err != nil { + return ManagedRuntimeUninstallResult{}, err + } + _, bundleRoot, err := managedRuntimeBundleRoot(i.InstallRoot, request.BundleID) + if err != nil { + return ManagedRuntimeUninstallResult{}, err + } + _, statErr := os.Stat(bundleRoot) + removed := false + if statErr == nil { + if err := os.RemoveAll(bundleRoot); err != nil { + return ManagedRuntimeUninstallResult{}, err + } + removed = true + } else if !errors.Is(statErr, os.ErrNotExist) { + return ManagedRuntimeUninstallResult{}, statErr + } + return ManagedRuntimeUninstallResult{ + BundleID: request.BundleID, + Root: bundleRoot, + Removed: removed, + ScopedOnly: true, + ManifestPath: filepath.Join(bundleRoot, managedRuntimeInstallManifestName), + }, nil +} + +func (i ManagedRuntimeBundleInstaller) Reinstall(ctx context.Context, request ManagedRuntimeInstallRequest) (ManagedRuntimeReinstallResult, error) { + uninstall, err := i.Uninstall(ctx, ManagedRuntimeUninstallRequest{BundleID: request.BundleID}) + if err != nil { + return ManagedRuntimeReinstallResult{}, err + } + install, err := i.Install(ctx, request) + if err != nil { + return ManagedRuntimeReinstallResult{}, err + } + doctor, err := i.Doctor(ctx, ManagedRuntimeDoctorRequest{ + BundleID: request.BundleID, + TargetOS: request.TargetOS, + TargetArch: request.TargetArch, + }) + if err != nil { + return ManagedRuntimeReinstallResult{}, err + } + return ManagedRuntimeReinstallResult{ + Uninstall: uninstall, + Install: install, + Doctor: doctor, + }, nil +} + +func (i ManagedRuntimeBundleInstaller) fetchPinnedObjects(ctx context.Context, source ManagedRuntimeBundleObjectSource, bundle core.ManagedRuntimeBundleDescriptor) ([]byte, []byte, []byte, error) { + artifact, err := fetchManagedRuntimePinnedObject(ctx, i.Catalog, source, bundle, "artifact", bundle.ArtifactName, bundle.ArtifactDigest) + if err != nil { + return nil, nil, nil, err + } + checksum, err := fetchManagedRuntimePinnedObject(ctx, i.Catalog, source, bundle, "checksum", bundle.ChecksumName, bundle.ChecksumDigest) + if err != nil { + return nil, nil, nil, err + } + signature, err := fetchManagedRuntimePinnedObject(ctx, i.Catalog, source, bundle, "signature", bundle.SignatureName, bundle.SignatureDigest) + if err != nil { + return nil, nil, nil, err + } + return artifact, checksum, signature, nil +} + +func (i ManagedRuntimeBundleInstaller) source() ManagedRuntimeBundleObjectSource { + if i.Source != nil { + return i.Source + } + return HTTPManagedRuntimeBundleObjectSource{Client: i.HTTPClient} +} + +func (i ManagedRuntimeBundleInstaller) now() time.Time { + if i.Now != nil { + if now := i.Now(); !now.IsZero() { + return now.UTC() + } + } + return time.Now().UTC() +} + +func fetchManagedRuntimePinnedObject(ctx context.Context, catalog ManagedRuntimeBundleCatalog, source ManagedRuntimeBundleObjectSource, bundle core.ManagedRuntimeBundleDescriptor, kind, name, wantDigest string) ([]byte, error) { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("managed runtime %s object name is required", kind) + } + if strings.TrimSpace(wantDigest) == "" { + return nil, fmt.Errorf("managed runtime %s digest is required", kind) + } + object, err := source.FetchManagedRuntimeBundleObject(ctx, ManagedRuntimeBundleObjectRequest{ + BundleID: bundle.BundleID, + Kind: kind, + Name: name, + URL: managedRuntimeObjectURL(catalog.SourceBaseURL, name), + }) + if err != nil { + return nil, fmt.Errorf("fetch managed runtime %s object: %w", kind, err) + } + if err := verifyManagedRuntimeDigest(kind, object, wantDigest); err != nil { + return nil, err + } + return object, nil +} + +func managedRuntimeObjectURL(base, name string) string { + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" { + return name + } + return base + "/" + name +} + +func verifyManagedRuntimeDigest(kind string, content []byte, want string) error { + want = strings.TrimSpace(want) + got := "sha256:" + managedRuntimeDigestHex(content) + if got != want { + return fmt.Errorf("managed runtime %s digest mismatch: got %s want %s", kind, got, want) + } + return nil +} + +func verifyManagedRuntimeChecksum(bundle core.ManagedRuntimeBundleDescriptor, checksum []byte) error { + wantDigest := strings.TrimPrefix(bundle.ArtifactDigest, "sha256:") + lines := bytes.Split(checksum, []byte{'\n'}) + for _, line := range lines { + fields := strings.Fields(string(line)) + if len(fields) < 2 { + continue + } + if fields[0] == wantDigest && fields[len(fields)-1] == bundle.ArtifactName { + return nil + } + } + return fmt.Errorf("managed runtime checksum object does not pin artifact %q to %s", bundle.ArtifactName, bundle.ArtifactDigest) +} + +func managedRuntimeDigestHex(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +func managedRuntimeBundleRoot(installRoot, bundleID string) (string, string, error) { + if strings.TrimSpace(installRoot) == "" { + return "", "", errors.New("managed runtime install root is required") + } + if !managedRuntimeSafePathPart(bundleID) { + return "", "", fmt.Errorf("unsafe managed runtime bundle id %q", bundleID) + } + root, err := filepath.Abs(installRoot) + if err != nil { + return "", "", err + } + if err := managedRuntimeRejectSymlinkedInstallRoot(root); err != nil { + return "", "", err + } + bundleRoot := filepath.Join(root, bundleID) + if err := managedRuntimeRequirePathUnderRoot(root, bundleRoot); err != nil { + return "", "", err + } + return root, bundleRoot, nil +} + +func managedRuntimeRejectSymlinkedInstallRoot(root string) error { + info, err := os.Lstat(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("managed runtime install root %q is a symlink", root) + } + return nil +} + +func managedRuntimeSafePathPart(value string) bool { + value = strings.TrimSpace(value) + if value == "" || value == "." || value == ".." { + return false + } + if filepath.IsAbs(value) || path.IsAbs(value) || filepath.VolumeName(value) != "" { + return false + } + if strings.Contains(value, "/") || strings.Contains(value, "\\") { + return false + } + return filepath.Clean(value) == value +} + +func managedRuntimeRequirePathUnderRoot(root, candidate string) error { + root, err := filepath.Abs(root) + if err != nil { + return err + } + candidate, err = filepath.Abs(candidate) + if err != nil { + return err + } + rel, err := filepath.Rel(root, candidate) + if err != nil { + return err + } + if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return fmt.Errorf("managed runtime path %q escapes root %q", candidate, root) + } + return nil +} + +func managedRuntimeRequireFileUnderRoot(root, candidate string) error { + if err := managedRuntimeRequirePathUnderRoot(root, candidate); err != nil { + return err + } + if err := managedRuntimeRejectSymlinkPath(root, candidate); err != nil { + return err + } + info, err := os.Lstat(candidate) + if err != nil { + return err + } + if info.IsDir() { + return fmt.Errorf("%q is a directory", candidate) + } + return nil +} + +func managedRuntimeRejectSymlinkPath(root, candidate string) error { + root, err := filepath.Abs(root) + if err != nil { + return err + } + candidate, err = filepath.Abs(candidate) + if err != nil { + return err + } + rel, err := filepath.Rel(root, candidate) + if err != nil { + return err + } + current := root + for _, part := range strings.Split(rel, string(os.PathSeparator)) { + if part == "" || part == "." { + continue + } + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("managed runtime path %q contains symlink %q", candidate, current) + } + } + return nil +} + +func extractManagedRuntimeTarGzip(content []byte, dest string) error { + gz, err := gzip.NewReader(bytes.NewReader(content)) + if err != nil { + return err + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + header, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + name, err := cleanManagedRuntimeArchivePath(header.Name) + if err != nil { + return err + } + target := filepath.Join(dest, filepath.FromSlash(name)) + if err := managedRuntimeRequirePathUnderRoot(dest, target); err != nil { + return err + } + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, modePerm(header.FileInfo().Mode())|0o700); err != nil { + return err + } + case tar.TypeReg, tar.TypeRegA: + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + mode := modePerm(header.FileInfo().Mode()) + if mode == 0 { + mode = 0o600 + } + file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + _, copyErr := io.Copy(file, tr) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + default: + return fmt.Errorf("unsafe archive entry type %d for %q", header.Typeflag, header.Name) + } + } +} + +func cleanManagedRuntimeArchivePath(name string) (string, error) { + if strings.TrimSpace(name) == "" || strings.Contains(name, "\\") || path.IsAbs(name) { + return "", fmt.Errorf("unsafe archive path %q", name) + } + clean := path.Clean(name) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("unsafe archive path %q", name) + } + return clean, nil +} + +func modePerm(mode os.FileMode) os.FileMode { + return mode.Perm() +} + +func writeManagedRuntimeManifest(path string, manifest managedRuntimeInstallManifest) error { + content, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + content = append(content, '\n') + return os.WriteFile(path, content, 0o600) +} + +func readManagedRuntimeManifest(path string) (managedRuntimeInstallManifest, error) { + content, err := os.ReadFile(path) + if err != nil { + return managedRuntimeInstallManifest{}, err + } + var manifest managedRuntimeInstallManifest + if err := json.Unmarshal(content, &manifest); err != nil { + return managedRuntimeInstallManifest{}, err + } + return manifest, nil +} diff --git a/container/managed_runtime_installer_test.go b/container/managed_runtime_installer_test.go new file mode 100644 index 0000000..66f66cd --- /dev/null +++ b/container/managed_runtime_installer_test.go @@ -0,0 +1,287 @@ +package container + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestManagedRuntimeBundleInstallerLifecycleUsesScopedRootAndPinnedObjects(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + bundle := catalog.Bundles[0] + installRoot := t.TempDir() + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: installRoot, + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + + installed, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: bundle.BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("install managed runtime bundle: %v", err) + } + if installed.BundleID != bundle.BundleID { + t.Fatalf("installed bundle id = %q, want %q", installed.BundleID, bundle.BundleID) + } + if !strings.HasPrefix(installed.Root, installRoot+string(os.PathSeparator)) { + t.Fatalf("installed root %q escaped install root %q", installed.Root, installRoot) + } + wantCommand := filepath.Join(installed.Root, "bin", "nerdctl") + if installed.CommandPath != wantCommand { + t.Fatalf("command path = %q, want %q", installed.CommandPath, wantCommand) + } + if _, err := os.Stat(installed.CommandPath); err != nil { + t.Fatalf("installed command missing: %v", err) + } + if _, err := os.Stat(installed.ManifestPath); err != nil { + t.Fatalf("install manifest missing: %v", err) + } + + doctor, err := installer.Doctor(ctx, ManagedRuntimeDoctorRequest{ + BundleID: bundle.BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("doctor managed runtime bundle: %v", err) + } + if doctor.Status != ManagedRuntimeLifecycleStatusOK { + t.Fatalf("doctor status = %q, want ok: %#v", doctor.Status, doctor) + } + if !doctor.ScopedStoreEnforced { + t.Fatalf("doctor did not preserve scoped store evidence: %#v", doctor) + } + + sibling := filepath.Join(installRoot, "sibling.txt") + if err := os.WriteFile(sibling, []byte("keep"), 0o600); err != nil { + t.Fatalf("write sibling: %v", err) + } + uninstalled, err := installer.Uninstall(ctx, ManagedRuntimeUninstallRequest{ + BundleID: bundle.BundleID, + }) + if err != nil { + t.Fatalf("uninstall managed runtime bundle: %v", err) + } + if !uninstalled.Removed { + t.Fatalf("uninstall removed = false, want true") + } + if _, err := os.Stat(installed.Root); !os.IsNotExist(err) { + t.Fatalf("installed root still exists or unexpected stat error: %v", err) + } + if _, err := os.Stat(sibling); err != nil { + t.Fatalf("uninstall removed install-root sibling: %v", err) + } + + reinstalled, err := installer.Reinstall(ctx, ManagedRuntimeInstallRequest{ + BundleID: bundle.BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("reinstall managed runtime bundle: %v", err) + } + if reinstalled.Install.CommandPath == "" || reinstalled.Doctor.Status != ManagedRuntimeLifecycleStatusOK { + t.Fatalf("reinstall did not finish with healthy doctor result: %#v", reinstalled) + } +} + +func TestManagedRuntimeBundleInstallerRejectsUnpinnedSignatureObject(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + objects[catalog.Bundles[0].SignatureName] = []byte("tampered signature") + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: t.TempDir(), + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + + _, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err == nil || !strings.Contains(err.Error(), "signature digest") { + t.Fatalf("install error = %v, want signature digest failure", err) + } +} + +func TestManagedRuntimeBundleInstallerRejectsArchivePathEscape(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "../escape": "bad", + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + installRoot := t.TempDir() + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: installRoot, + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + + _, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err == nil || !strings.Contains(err.Error(), "unsafe archive path") { + t.Fatalf("install error = %v, want unsafe archive path failure", err) + } + if _, err := os.Stat(filepath.Join(installRoot, "..", "escape")); !os.IsNotExist(err) { + t.Fatalf("archive escape was written or unexpected stat error: %v", err) + } +} + +func TestManagedRuntimeBundleInstallerRejectsSymlinkedInstallRoot(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + linkRoot := filepath.Join(t.TempDir(), "managed-runtime") + if err := os.Symlink(t.TempDir(), linkRoot); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: linkRoot, + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + + _, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("install error = %v, want symlink install root failure", err) + } +} + +func TestManagedRuntimeBundleInstallerDoctorRejectsCommandSymlink(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: t.TempDir(), + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + installed, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("install managed runtime bundle: %v", err) + } + outside := filepath.Join(t.TempDir(), "nerdctl") + if err := os.WriteFile(outside, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write outside runtime: %v", err) + } + if err := os.Remove(installed.CommandPath); err != nil { + t.Fatalf("remove command: %v", err) + } + if err := os.Symlink(outside, installed.CommandPath); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + doctor, err := installer.Doctor(ctx, ManagedRuntimeDoctorRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("doctor managed runtime bundle: %v", err) + } + if doctor.Status == ManagedRuntimeLifecycleStatusOK || !strings.Contains(doctor.Reason, "symlink") { + t.Fatalf("doctor = %#v, want degraded symlink result", doctor) + } +} + +func managedRuntimeTestSource(objects map[string][]byte) ManagedRuntimeBundleObjectSource { + return ManagedRuntimeBundleObjectSourceFunc(func(_ context.Context, request ManagedRuntimeBundleObjectRequest) ([]byte, error) { + object, ok := objects[request.Name] + if !ok { + return nil, fmt.Errorf("missing object %q", request.Name) + } + return bytes.Clone(object), nil + }) +} + +func testManagedRuntimeCatalogAndObjects(t *testing.T, archiveFiles map[string]string) (ManagedRuntimeBundleCatalog, map[string][]byte) { + t.Helper() + artifact := testManagedRuntimeTarGzip(t, archiveFiles) + artifactDigest := "sha256:" + testManagedRuntimeSHA256(artifact) + artifactName := "nerdctl-full-test-linux-amd64.tar.gz" + checksum := []byte(strings.TrimPrefix(artifactDigest, "sha256:") + " " + artifactName + "\n") + signature := []byte("test signature over " + artifactName + "\n") + checksumDigest := "sha256:" + testManagedRuntimeSHA256(checksum) + signatureDigest := "sha256:" + testManagedRuntimeSHA256(signature) + + catalog := validManagedRuntimeBundleCatalog() + catalog.SourceBaseURL = "memory://managed-runtime-test" + catalog.Bundles[0].ArtifactName = artifactName + catalog.Bundles[0].ArtifactDigest = artifactDigest + catalog.Bundles[0].ChecksumDigest = checksumDigest + catalog.Bundles[0].SignatureDigest = signatureDigest + catalog.Bundles[0].SignatureSubject.ArtifactDigest = artifactDigest + return catalog, map[string][]byte{ + artifactName: artifact, + catalog.Bundles[0].ChecksumName: checksum, + catalog.Bundles[0].SignatureName: signature, + } +} + +func testManagedRuntimeTarGzip(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for name, content := range files { + body := []byte(content) + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(body)), + }); err != nil { + t.Fatalf("write tar header: %v", err) + } + if _, err := tw.Write(body); err != nil { + t.Fatalf("write tar body: %v", err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + return buf.Bytes() +} + +func testManagedRuntimeSHA256(content []byte) string { + sum := sha256.Sum256(content) + return fmt.Sprintf("%x", sum[:]) +} diff --git a/internal/managed_runtime_cli.go b/internal/managed_runtime_cli.go new file mode 100644 index 0000000..60cc537 --- /dev/null +++ b/internal/managed_runtime_cli.go @@ -0,0 +1,124 @@ +package internal + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/GoCodeAlone/workflow-plugin-compute-container/container" +) + +func RunManagedRuntimeCLI(ctx context.Context, args []string, stdout, stderr io.Writer) int { + if stdout == nil { + stdout = io.Discard + } + if stderr == nil { + stderr = io.Discard + } + if len(args) == 0 || args[0] != "managed-runtime" { + _, _ = fmt.Fprintln(stderr, "usage: managed-runtime [flags]") + return 2 + } + if len(args) < 2 { + _, _ = fmt.Fprintln(stderr, "managed-runtime subcommand is required") + return 2 + } + command := args[1] + cfg, ok := parseManagedRuntimeCLIFlags(command, args[2:], stderr) + if !ok { + return 2 + } + installer, err := cfg.installer() + if err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + var result any + switch command { + case "install": + result, err = installer.Install(ctx, cfg.installRequest()) + case "doctor": + result, err = installer.Doctor(ctx, cfg.doctorRequest()) + case "uninstall": + result, err = installer.Uninstall(ctx, container.ManagedRuntimeUninstallRequest{BundleID: cfg.BundleID}) + case "reinstall": + result, err = installer.Reinstall(ctx, cfg.installRequest()) + default: + _, _ = fmt.Fprintf(stderr, "unknown managed-runtime subcommand %q\n", command) + return 2 + } + if err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + if err := json.NewEncoder(stdout).Encode(result); err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + return 0 +} + +type managedRuntimeCLIConfig struct { + CatalogPath string + InstallRoot string + BundleID string + TargetOS string + TargetArch string +} + +func parseManagedRuntimeCLIFlags(command string, args []string, stderr io.Writer) (managedRuntimeCLIConfig, bool) { + fs := flag.NewFlagSet("managed-runtime "+command, flag.ContinueOnError) + fs.SetOutput(stderr) + cfg := managedRuntimeCLIConfig{} + fs.StringVar(&cfg.CatalogPath, "catalog", "", "managed runtime bundle catalog path") + fs.StringVar(&cfg.InstallRoot, "install-root", "", "agent-managed install root") + fs.StringVar(&cfg.BundleID, "bundle-id", "", "managed runtime bundle id") + fs.StringVar(&cfg.TargetOS, "target-os", "", "target operating system") + fs.StringVar(&cfg.TargetArch, "target-arch", "", "target architecture") + if err := fs.Parse(args); err != nil { + return managedRuntimeCLIConfig{}, false + } + if cfg.CatalogPath == "" || cfg.InstallRoot == "" || cfg.BundleID == "" { + _, _ = fmt.Fprintln(stderr, "--catalog, --install-root, and --bundle-id are required") + return managedRuntimeCLIConfig{}, false + } + if command != "uninstall" && (cfg.TargetOS == "" || cfg.TargetArch == "") { + _, _ = fmt.Fprintln(stderr, "--target-os and --target-arch are required") + return managedRuntimeCLIConfig{}, false + } + return cfg, true +} + +func (c managedRuntimeCLIConfig) installer() (container.ManagedRuntimeBundleInstaller, error) { + content, err := os.ReadFile(c.CatalogPath) + if err != nil { + return container.ManagedRuntimeBundleInstaller{}, fmt.Errorf("read managed runtime catalog: %w", err) + } + var catalog container.ManagedRuntimeBundleCatalog + if err := json.Unmarshal(content, &catalog); err != nil { + return container.ManagedRuntimeBundleInstaller{}, fmt.Errorf("decode managed runtime catalog: %w", err) + } + return container.ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: c.InstallRoot, + }, nil +} + +func (c managedRuntimeCLIConfig) installRequest() container.ManagedRuntimeInstallRequest { + return container.ManagedRuntimeInstallRequest{ + BundleID: c.BundleID, + TargetOS: c.TargetOS, + TargetArch: c.TargetArch, + } +} + +func (c managedRuntimeCLIConfig) doctorRequest() container.ManagedRuntimeDoctorRequest { + return container.ManagedRuntimeDoctorRequest{ + BundleID: c.BundleID, + TargetOS: c.TargetOS, + TargetArch: c.TargetArch, + } +} diff --git a/internal/managed_runtime_cli_test.go b/internal/managed_runtime_cli_test.go new file mode 100644 index 0000000..2a7e1d5 --- /dev/null +++ b/internal/managed_runtime_cli_test.go @@ -0,0 +1,183 @@ +package internal + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/GoCodeAlone/workflow-plugin-compute-container/container" + core "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol" +) + +func TestManagedRuntimeCLIInstallDoctorUninstallLifecycle(t *testing.T) { + catalog, objects := testManagedRuntimeCLICatalogAndObjects(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + object, ok := objects[filepath.Base(r.URL.Path)] + if !ok { + http.NotFound(w, r) + return + } + _, _ = w.Write(object) + })) + defer server.Close() + catalog.SourceBaseURL = server.URL + catalogPath := filepath.Join(t.TempDir(), "managed-runtime-bundles.json") + content, err := json.Marshal(catalog) + if err != nil { + t.Fatalf("marshal catalog: %v", err) + } + if err := os.WriteFile(catalogPath, content, 0o600); err != nil { + t.Fatalf("write catalog: %v", err) + } + installRoot := t.TempDir() + args := []string{ + "--catalog", catalogPath, + "--install-root", installRoot, + "--bundle-id", catalog.Bundles[0].BundleID, + "--target-os", "linux", + "--target-arch", "amd64", + } + + var stdout bytes.Buffer + if code := RunManagedRuntimeCLI(context.Background(), append([]string{"managed-runtime", "install"}, args...), &stdout, io.Discard); code != 0 { + t.Fatalf("install exit code = %d stdout=%s", code, stdout.String()) + } + var install container.ManagedRuntimeInstallResult + if err := json.Unmarshal(stdout.Bytes(), &install); err != nil { + t.Fatalf("decode install JSON: %v", err) + } + if install.CommandPath == "" { + t.Fatalf("install command path is empty: %#v", install) + } + + stdout.Reset() + if code := RunManagedRuntimeCLI(context.Background(), append([]string{"managed-runtime", "doctor"}, args...), &stdout, io.Discard); code != 0 { + t.Fatalf("doctor exit code = %d stdout=%s", code, stdout.String()) + } + var doctor container.ManagedRuntimeDoctorResult + if err := json.Unmarshal(stdout.Bytes(), &doctor); err != nil { + t.Fatalf("decode doctor JSON: %v", err) + } + if doctor.Status != container.ManagedRuntimeLifecycleStatusOK { + t.Fatalf("doctor status = %q, want ok: %#v", doctor.Status, doctor) + } + + stdout.Reset() + if code := RunManagedRuntimeCLI(context.Background(), append([]string{"managed-runtime", "uninstall"}, args...), &stdout, io.Discard); code != 0 { + t.Fatalf("uninstall exit code = %d stdout=%s", code, stdout.String()) + } + var uninstall container.ManagedRuntimeUninstallResult + if err := json.Unmarshal(stdout.Bytes(), &uninstall); err != nil { + t.Fatalf("decode uninstall JSON: %v", err) + } + if !uninstall.Removed || !uninstall.ScopedOnly { + t.Fatalf("uninstall result = %#v, want scoped removal", uninstall) + } +} + +func testManagedRuntimeCLICatalogAndObjects(t *testing.T) (container.ManagedRuntimeBundleCatalog, map[string][]byte) { + t.Helper() + artifact := testManagedRuntimeCLITarGzip(t) + artifactDigest := "sha256:" + testManagedRuntimeCLISHA256(artifact) + artifactName := "nerdctl-full-test-linux-amd64.tar.gz" + checksum := []byte(testManagedRuntimeCLISHA256(artifact) + " " + artifactName + "\n") + signature := []byte("test signature\n") + checksumDigest := "sha256:" + testManagedRuntimeCLISHA256(checksum) + signatureDigest := "sha256:" + testManagedRuntimeCLISHA256(signature) + bundle := core.ManagedRuntimeBundleDescriptor{ + ProtocolVersion: core.Version, + BundleID: "managed-containerd-linux-amd64", + Family: core.RuntimeBackendFamilyContainerd, + Tool: core.ContainerRuntimeNerdctl, + Version: "v2.3.1", + OS: "linux", + Arch: "amd64", + ArtifactName: artifactName, + ArtifactDigest: artifactDigest, + ChecksumName: "SHA256SUMS", + ChecksumDigest: checksumDigest, + SignatureName: "SHA256SUMS.asc", + SignatureDigest: signatureDigest, + SignatureIssuer: "containerd/nerdctl release", + SignatureKeyID: "containerd-nerdctl-release", + TrustRootDigest: "sha256:6fad18923304aba73378965a8bac49bf44a3a22da73df42ca6a081c726c36b34", + SignatureSubject: core.ManagedRuntimeSignatureSubject{ + ArtifactDigest: artifactDigest, + RuntimeFamily: core.RuntimeBackendFamilyContainerd, + OS: "linux", + Arch: "amd64", + Version: "v2.3.1", + Channel: "stable", + ConformanceProfile: "distroless-static-v1", + ScopedStorePolicyDigest: "sha256:311ab6244d878cf7280a5927f5af6063337ec262e35fd7c84c6579d07591337e", + }, + ValidUntil: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC), + UpdatePolicy: core.ManagedRuntimeUpdatePolicy{ + Channel: "stable", + MinSupportedVersion: "v2.3.1", + }, + CVEPolicy: core.ManagedRuntimeCVEPolicy{ + PolicyDigest: "sha256:5bc9d3baf40fe716e68bfc469c53040351288caaaa048650aeadd6320ca6d7c1", + UpdatedByVersion: "v2.3.1", + }, + ScopedStore: core.ManagedRuntimeScopedStorePolicy{ + Required: true, + NamespaceStrategy: "opaque-worker-pool-scope", + StoreStrategy: "workflow-owned-content-store", + PolicyDigest: "sha256:311ab6244d878cf7280a5927f5af6063337ec262e35fd7c84c6579d07591337e", + CleanupRequired: true, + HostGlobalVisibilityForbidden: true, + }, + SupportedTargets: []core.ManagedRuntimeTarget{{OS: "linux", Arch: "amd64"}}, + ConformanceProfile: "distroless-static-v1", + InstallBurden: core.RuntimeInstallBundled, + } + return container.ManagedRuntimeBundleCatalog{ + ReleaseTag: "v2.3.1", + GeneratedAt: time.Unix(1_700_000_000, 0).UTC(), + MinimumVersion: "v2.3.1", + StableSigningKey: "containerd-nerdctl-release", + Bundles: []core.ManagedRuntimeBundleDescriptor{bundle}, + }, map[string][]byte{ + artifactName: artifact, + "SHA256SUMS": checksum, + "SHA256SUMS.asc": signature, + } +} + +func testManagedRuntimeCLITarGzip(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + body := []byte("#!/bin/sh\nprintf nerdctl-test\n") + if err := tw.WriteHeader(&tar.Header{Name: "bin/nerdctl", Mode: 0o755, Size: int64(len(body))}); err != nil { + t.Fatalf("write tar header: %v", err) + } + if _, err := tw.Write(body); err != nil { + t.Fatalf("write tar body: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + return buf.Bytes() +} + +func testManagedRuntimeCLISHA256(content []byte) string { + sum := sha256.Sum256(content) + return fmt.Sprintf("%x", sum[:]) +} diff --git a/internal/plugin_test.go b/internal/plugin_test.go index 5c6b884..272d287 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -182,6 +182,47 @@ func TestPluginContractsAdvertiseRuntimeBackendReports(t *testing.T) { t.Fatalf("RuntimeBackendReport protocol type not advertised: %+v", contracts.ProtocolTypes) } +func TestPluginContractsAdvertiseManagedRuntimeInstaller(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "plugin.contracts.json")) + if err != nil { + t.Fatal(err) + } + var contracts struct { + Contracts []struct { + Name string `json:"name"` + Wire string `json:"wire"` + GoType string `json:"goType"` + Commands []string `json:"commands"` + Produces []string `json:"produces"` + Guarantees []string `json:"guarantees"` + } `json:"contracts"` + } + if err := json.Unmarshal(data, &contracts); err != nil { + t.Fatal(err) + } + for _, contract := range contracts.Contracts { + if contract.Name != "ManagedRuntimeBundleInstaller" { + continue + } + if contract.Wire != "json" || + contract.GoType != "github.com/GoCodeAlone/workflow-plugin-compute-container/container.ManagedRuntimeBundleInstaller" || + !slices.Contains(contract.Commands, "managed-runtime install") || + !slices.Contains(contract.Commands, "managed-runtime doctor") || + !slices.Contains(contract.Commands, "managed-runtime uninstall") || + !slices.Contains(contract.Commands, "managed-runtime reinstall") || + !slices.Contains(contract.Produces, "ManagedRuntimeInstallResult") || + !slices.Contains(contract.Produces, "ManagedRuntimeDoctorResult") || + !slices.Contains(contract.Produces, "ManagedRuntimeUninstallResult") || + !slices.Contains(contract.Produces, "ManagedRuntimeReinstallResult") || + !slices.Contains(contract.Guarantees, "scoped-install-root") || + !slices.Contains(contract.Guarantees, "pinned-artifact-checksum-signature-digests") { + t.Fatalf("managed runtime installer contract incomplete: %+v", contract) + } + return + } + t.Fatalf("ManagedRuntimeBundleInstaller contract not advertised: %+v", contracts.Contracts) +} + func TestManagedRuntimeBundlePackagingScriptMatchesCatalogPins(t *testing.T) { script, err := os.ReadFile(filepath.Join("..", "scripts", "package-managed-runtime-bundle.sh")) if err != nil { diff --git a/plugin.contracts.json b/plugin.contracts.json index adbb358..93f8257 100644 --- a/plugin.contracts.json +++ b/plugin.contracts.json @@ -1,6 +1,30 @@ { "version": "1", - "contracts": [], + "contracts": [ + { + "name": "ManagedRuntimeBundleInstaller", + "wire": "json", + "goType": "github.com/GoCodeAlone/workflow-plugin-compute-container/container.ManagedRuntimeBundleInstaller", + "commands": [ + "managed-runtime install", + "managed-runtime doctor", + "managed-runtime uninstall", + "managed-runtime reinstall" + ], + "produces": [ + "ManagedRuntimeInstallResult", + "ManagedRuntimeDoctorResult", + "ManagedRuntimeUninstallResult", + "ManagedRuntimeReinstallResult" + ], + "guarantees": [ + "scoped-install-root", + "pinned-artifact-checksum-signature-digests", + "safe-tar-gzip-extraction", + "scoped-uninstall-only" + ] + } + ], "protocolTypes": [ { "name": "RuntimeBackendReport", From 75264fd796d788f7b779538c49d6e2c6e412acf2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 11 Jun 2026 20:46:08 -0400 Subject: [PATCH 2/6] feat: add managed runtime installer contract --- README.md | 31 +- cmd/workflow-plugin-compute-container/main.go | 6 + container/managed_runtime_bundle.go | 21 + container/managed_runtime_installer.go | 858 ++++++++++++++++++ container/managed_runtime_installer_test.go | 482 ++++++++++ internal/managed_runtime_cli.go | 124 +++ internal/managed_runtime_cli_test.go | 234 +++++ internal/plugin_test.go | 43 + managed-runtime-bundles.json | 15 +- plugin.contracts.json | 26 +- scripts/package-managed-runtime-bundle.sh | 10 +- 11 files changed, 1842 insertions(+), 8 deletions(-) create mode 100644 container/managed_runtime_installer.go create mode 100644 container/managed_runtime_installer_test.go create mode 100644 internal/managed_runtime_cli.go create mode 100644 internal/managed_runtime_cli_test.go diff --git a/README.md b/README.md index cc7e6f5..00c4a24 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,16 @@ host's default container namespace. Managed runtime release packaging verifies the upstream `SHA256SUMS` and `SHA256SUMS.asc` digests and emits a source manifest in the plugin release -archive. Agent-side download, extraction, signature verification, and lifecycle -wiring are intentionally left to the workflow-compute host integration phase so -the plugin does not advertise a host-installed `nerdctl` as bundled. +archive. The public `ManagedRuntimeBundleInstaller` helper and +`managed-runtime` subcommands provide the reusable agent-side lifecycle contract: +download pinned artifact/checksum/signature objects from catalog metadata, +verify the detached OpenPGP signature against the catalog-pinned trust root, +extract the bundle into an agent-owned scoped install root, write an install +manifest with extracted file digests, doctor the scoped install, remove only the +scoped bundle root, and reinstall from the same pinned metadata without removing +the old install before the replacement has been staged. Workflow Compute hosts +consume this contract instead of implementing private host-only bundle lifecycle +behavior. The default conformance image reference is the Debian 13 distroless static index: @@ -77,3 +84,21 @@ Run installed runtime smoke probes explicitly: ```sh WORKFLOW_COMPUTE_RUNTIME_PROBE_REAL=1 GOWORK=off go test ./container -run TestDockerCompatibleRuntimeProbeRealBackends -count=1 -v ``` + +Install and verify a managed bundle from catalog metadata: + +```sh +workflow-plugin-compute-container managed-runtime install \ + --catalog managed-runtime-bundles.json \ + --install-root "$HOME/.local/share/workflow-compute/managed-runtime" \ + --bundle-id managed-containerd-linux-amd64 \ + --target-os linux \ + --target-arch amd64 + +workflow-plugin-compute-container managed-runtime doctor \ + --catalog managed-runtime-bundles.json \ + --install-root "$HOME/.local/share/workflow-compute/managed-runtime" \ + --bundle-id managed-containerd-linux-amd64 \ + --target-os linux \ + --target-arch amd64 +``` diff --git a/cmd/workflow-plugin-compute-container/main.go b/cmd/workflow-plugin-compute-container/main.go index 7d97d3c..7ba78f0 100644 --- a/cmd/workflow-plugin-compute-container/main.go +++ b/cmd/workflow-plugin-compute-container/main.go @@ -1,6 +1,9 @@ package main import ( + "context" + "os" + plugin "github.com/GoCodeAlone/workflow-plugin-compute-container/internal" sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" ) @@ -8,6 +11,9 @@ import ( var version = "0.0.0" func main() { + if len(os.Args) > 1 && os.Args[1] == "managed-runtime" { + os.Exit(plugin.RunManagedRuntimeCLI(context.Background(), os.Args[1:], os.Stdout, os.Stderr)) + } plugin.Version = version sdk.Serve(plugin.NewPlugin(), sdk.WithBuildVersion(sdk.ResolveBuildVersion(plugin.Version)), diff --git a/container/managed_runtime_bundle.go b/container/managed_runtime_bundle.go index 91133da..f034d32 100644 --- a/container/managed_runtime_bundle.go +++ b/container/managed_runtime_bundle.go @@ -16,12 +16,21 @@ type ManagedRuntimeBundleCatalog struct { SourceBaseURL string `json:"source_base_url,omitempty"` GeneratedAt time.Time `json:"generated_at"` Bundles []core.ManagedRuntimeBundleDescriptor `json:"bundles"` + TrustRoots []ManagedRuntimeTrustRoot `json:"trust_roots,omitempty"` BlockedVersions []string `json:"blocked_versions,omitempty"` RevokedKeyIDs []string `json:"revoked_key_ids,omitempty"` MinimumVersion string `json:"minimum_version,omitempty"` StableSigningKey string `json:"stable_signing_key,omitempty"` } +type ManagedRuntimeTrustRoot struct { + KeyID string `json:"key_id"` + Name string `json:"name"` + URL string `json:"url,omitempty"` + Digest string `json:"digest"` + Issuer string `json:"issuer,omitempty"` +} + func (c ManagedRuntimeBundleCatalog) Bundle(bundleID string, now time.Time) (core.ManagedRuntimeBundleDescriptor, error) { if strings.TrimSpace(bundleID) == "" { return core.ManagedRuntimeBundleDescriptor{}, errors.New("bundle_id is required") @@ -52,6 +61,18 @@ func (c ManagedRuntimeBundleCatalog) BundleForTarget(bundleID, targetOS, targetA return bundle, nil } +func (c ManagedRuntimeBundleCatalog) TrustRootForBundle(bundle core.ManagedRuntimeBundleDescriptor) (ManagedRuntimeTrustRoot, error) { + for _, root := range c.TrustRoots { + if root.KeyID == bundle.SignatureKeyID && root.Digest == bundle.TrustRootDigest { + if strings.TrimSpace(root.Name) == "" { + return ManagedRuntimeTrustRoot{}, fmt.Errorf("managed runtime trust root for key %q has no object name", bundle.SignatureKeyID) + } + return root, nil + } + } + return ManagedRuntimeTrustRoot{}, fmt.Errorf("managed runtime trust root for signing key %q and digest %q not found", bundle.SignatureKeyID, bundle.TrustRootDigest) +} + func validateManagedRuntimeBundlePolicy(c ManagedRuntimeBundleCatalog, bundle core.ManagedRuntimeBundleDescriptor, now time.Time) error { if err := bundle.ValidateAt(now); err != nil { return err diff --git a/container/managed_runtime_installer.go b/container/managed_runtime_installer.go new file mode 100644 index 0000000..ed1cd2a --- /dev/null +++ b/container/managed_runtime_installer.go @@ -0,0 +1,858 @@ +package container + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path" + "path/filepath" + "strings" + "time" + + core "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol" + "golang.org/x/crypto/openpgp" +) + +const managedRuntimeInstallManifestName = "wfcompute-managed-runtime-install.json" + +type ManagedRuntimeLifecycleStatus string + +const ( + ManagedRuntimeLifecycleStatusOK ManagedRuntimeLifecycleStatus = "ok" + ManagedRuntimeLifecycleStatusMissing ManagedRuntimeLifecycleStatus = "missing" + ManagedRuntimeLifecycleStatusDegraded ManagedRuntimeLifecycleStatus = "degraded" +) + +type ManagedRuntimeBundleInstaller struct { + Catalog ManagedRuntimeBundleCatalog + InstallRoot string + Source ManagedRuntimeBundleObjectSource + Now func() time.Time + HTTPClient *http.Client +} + +type ManagedRuntimeInstallRequest struct { + BundleID string + TargetOS string + TargetArch string +} + +type ManagedRuntimeDoctorRequest struct { + BundleID string + TargetOS string + TargetArch string +} + +type ManagedRuntimeUninstallRequest struct { + BundleID string +} + +type ManagedRuntimeBundleObjectRequest struct { + BundleID string + Kind string + Name string + URL string +} + +type ManagedRuntimeBundleObjectSource interface { + FetchManagedRuntimeBundleObject(context.Context, ManagedRuntimeBundleObjectRequest) ([]byte, error) +} + +type ManagedRuntimeBundleObjectSourceFunc func(context.Context, ManagedRuntimeBundleObjectRequest) ([]byte, error) + +func (f ManagedRuntimeBundleObjectSourceFunc) FetchManagedRuntimeBundleObject(ctx context.Context, request ManagedRuntimeBundleObjectRequest) ([]byte, error) { + return f(ctx, request) +} + +type HTTPManagedRuntimeBundleObjectSource struct { + Client *http.Client +} + +func (s HTTPManagedRuntimeBundleObjectSource) FetchManagedRuntimeBundleObject(ctx context.Context, request ManagedRuntimeBundleObjectRequest) ([]byte, error) { + if strings.TrimSpace(request.URL) == "" { + return nil, errors.New("managed runtime object URL is required") + } + client := s.Client + if client == nil { + client = http.DefaultClient + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, request.URL, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("fetch managed runtime object %q: %s", request.Name, resp.Status) + } + return io.ReadAll(resp.Body) +} + +type ManagedRuntimeInstallResult struct { + BundleID string `json:"bundle_id"` + Version string `json:"version"` + Root string `json:"root"` + CommandPath string `json:"command_path"` + ManifestPath string `json:"manifest_path"` + ArtifactDigest string `json:"artifact_digest"` + ChecksumDigest string `json:"checksum_digest"` + SignatureDigest string `json:"signature_digest"` + TrustRootDigest string `json:"trust_root_digest"` + ScopedStoreEnforced bool `json:"scoped_store_enforced"` + HostGlobalStoreExcluded bool `json:"host_global_store_excluded"` + FileDigests map[string]string `json:"file_digests"` + InstalledAt time.Time `json:"installed_at"` +} + +type ManagedRuntimeDoctorResult struct { + Status ManagedRuntimeLifecycleStatus `json:"status"` + BundleID string `json:"bundle_id"` + Version string `json:"version,omitempty"` + Root string `json:"root,omitempty"` + CommandPath string `json:"command_path,omitempty"` + ManifestPath string `json:"manifest_path,omitempty"` + ArtifactDigest string `json:"artifact_digest,omitempty"` + SignatureDigest string `json:"signature_digest,omitempty"` + ScopedStoreEnforced bool `json:"scoped_store_enforced,omitempty"` + HostGlobalStoreExcluded bool `json:"host_global_store_excluded,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type ManagedRuntimeUninstallResult struct { + BundleID string `json:"bundle_id"` + Root string `json:"root"` + Removed bool `json:"removed"` + ScopedOnly bool `json:"scoped_only"` + ManifestPath string `json:"manifest_path"` +} + +type ManagedRuntimeReinstallResult struct { + Uninstall ManagedRuntimeUninstallResult `json:"uninstall"` + Install ManagedRuntimeInstallResult `json:"install"` + Doctor ManagedRuntimeDoctorResult `json:"doctor"` +} + +type managedRuntimeInstallManifest struct { + ProtocolVersion string `json:"protocol_version"` + BundleID string `json:"bundle_id"` + Version string `json:"version"` + OS string `json:"os"` + Arch string `json:"arch"` + Root string `json:"root"` + CommandPath string `json:"command_path"` + ArtifactName string `json:"artifact_name"` + ArtifactDigest string `json:"artifact_digest"` + ChecksumName string `json:"checksum_name"` + ChecksumDigest string `json:"checksum_digest"` + SignatureName string `json:"signature_name"` + SignatureDigest string `json:"signature_digest"` + SignatureKeyID string `json:"signature_key_id"` + TrustRootDigest string `json:"trust_root_digest"` + ScopedStoreEnforced bool `json:"scoped_store_enforced"` + HostGlobalStoreExcluded bool `json:"host_global_store_excluded"` + FileDigests map[string]string `json:"file_digests"` + InstalledAt time.Time `json:"installed_at"` +} + +func (i ManagedRuntimeBundleInstaller) Install(ctx context.Context, request ManagedRuntimeInstallRequest) (ManagedRuntimeInstallResult, error) { + if err := ctx.Err(); err != nil { + return ManagedRuntimeInstallResult{}, err + } + now := i.now() + bundle, err := i.Catalog.BundleForTarget(request.BundleID, request.TargetOS, request.TargetArch, now) + if err != nil { + return ManagedRuntimeInstallResult{}, err + } + installRoot, bundleRoot, err := managedRuntimeBundleRoot(i.InstallRoot, bundle.BundleID) + if err != nil { + return ManagedRuntimeInstallResult{}, err + } + unlock, err := managedRuntimeAcquireBundleLock(installRoot, bundle.BundleID) + if err != nil { + return ManagedRuntimeInstallResult{}, err + } + defer unlock() + source := i.source() + artifact, checksum, signature, trustRoot, err := i.fetchPinnedObjects(ctx, source, bundle) + if err != nil { + return ManagedRuntimeInstallResult{}, err + } + if err := verifyManagedRuntimeChecksum(bundle, checksum); err != nil { + return ManagedRuntimeInstallResult{}, err + } + if err := verifyManagedRuntimeSignature(bundle, checksum, signature, trustRoot); err != nil { + return ManagedRuntimeInstallResult{}, err + } + if err := os.MkdirAll(installRoot, 0o700); err != nil { + return ManagedRuntimeInstallResult{}, err + } + stagingParent := filepath.Join(installRoot, ".staging") + if err := os.MkdirAll(stagingParent, 0o700); err != nil { + return ManagedRuntimeInstallResult{}, err + } + stageRoot, err := os.MkdirTemp(stagingParent, bundle.BundleID+"-") + if err != nil { + return ManagedRuntimeInstallResult{}, err + } + removeStage := true + defer func() { + if removeStage { + _ = os.RemoveAll(stageRoot) + } + }() + if err := extractManagedRuntimeTarGzip(artifact, stageRoot); err != nil { + return ManagedRuntimeInstallResult{}, err + } + commandPath := filepath.Join(stageRoot, "bin", "nerdctl") + if err := managedRuntimeRequireFileUnderRoot(stageRoot, commandPath); err != nil { + return ManagedRuntimeInstallResult{}, fmt.Errorf("managed runtime command: %w", err) + } + fileDigests, err := managedRuntimeFileDigests(stageRoot, "") + if err != nil { + return ManagedRuntimeInstallResult{}, err + } + result := ManagedRuntimeInstallResult{ + BundleID: bundle.BundleID, + Version: bundle.Version, + Root: bundleRoot, + CommandPath: filepath.Join(bundleRoot, "bin", "nerdctl"), + ManifestPath: filepath.Join(bundleRoot, managedRuntimeInstallManifestName), + ArtifactDigest: bundle.ArtifactDigest, + ChecksumDigest: bundle.ChecksumDigest, + SignatureDigest: bundle.SignatureDigest, + TrustRootDigest: bundle.TrustRootDigest, + ScopedStoreEnforced: bundle.ScopedStore.Required, + HostGlobalStoreExcluded: bundle.ScopedStore.HostGlobalVisibilityForbidden, + InstalledAt: now, + } + manifest := managedRuntimeInstallManifest{ + ProtocolVersion: core.Version, + BundleID: bundle.BundleID, + Version: bundle.Version, + OS: bundle.OS, + Arch: bundle.Arch, + Root: result.Root, + CommandPath: result.CommandPath, + ArtifactName: bundle.ArtifactName, + ArtifactDigest: bundle.ArtifactDigest, + ChecksumName: bundle.ChecksumName, + ChecksumDigest: bundle.ChecksumDigest, + SignatureName: bundle.SignatureName, + SignatureDigest: bundle.SignatureDigest, + SignatureKeyID: bundle.SignatureKeyID, + TrustRootDigest: bundle.TrustRootDigest, + ScopedStoreEnforced: result.ScopedStoreEnforced, + HostGlobalStoreExcluded: result.HostGlobalStoreExcluded, + FileDigests: fileDigests, + InstalledAt: now, + } + if err := writeManagedRuntimeManifest(filepath.Join(stageRoot, managedRuntimeInstallManifestName), manifest); err != nil { + return ManagedRuntimeInstallResult{}, err + } + if err := managedRuntimePromoteStage(stageRoot, bundleRoot); err != nil { + return ManagedRuntimeInstallResult{}, err + } + removeStage = false + return result, nil +} + +func (i ManagedRuntimeBundleInstaller) Doctor(ctx context.Context, request ManagedRuntimeDoctorRequest) (ManagedRuntimeDoctorResult, error) { + if err := ctx.Err(); err != nil { + return ManagedRuntimeDoctorResult{}, err + } + now := i.now() + bundle, err := i.Catalog.BundleForTarget(request.BundleID, request.TargetOS, request.TargetArch, now) + if err != nil { + return ManagedRuntimeDoctorResult{}, err + } + _, bundleRoot, err := managedRuntimeBundleRoot(i.InstallRoot, bundle.BundleID) + if err != nil { + return ManagedRuntimeDoctorResult{}, err + } + manifestPath := filepath.Join(bundleRoot, managedRuntimeInstallManifestName) + manifest, err := readManagedRuntimeManifest(manifestPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return ManagedRuntimeDoctorResult{ + Status: ManagedRuntimeLifecycleStatusMissing, + BundleID: bundle.BundleID, + Root: bundleRoot, + ManifestPath: manifestPath, + Reason: "install manifest is missing", + }, nil + } + return ManagedRuntimeDoctorResult{}, err + } + result := ManagedRuntimeDoctorResult{ + Status: ManagedRuntimeLifecycleStatusOK, + BundleID: bundle.BundleID, + Version: manifest.Version, + Root: bundleRoot, + CommandPath: filepath.Join(bundleRoot, "bin", "nerdctl"), + ManifestPath: manifestPath, + ArtifactDigest: manifest.ArtifactDigest, + SignatureDigest: manifest.SignatureDigest, + ScopedStoreEnforced: manifest.ScopedStoreEnforced, + HostGlobalStoreExcluded: manifest.HostGlobalStoreExcluded, + } + if manifest.BundleID != bundle.BundleID || + manifest.ArtifactDigest != bundle.ArtifactDigest || + manifest.SignatureDigest != bundle.SignatureDigest || + manifest.TrustRootDigest != bundle.TrustRootDigest { + result.Status = ManagedRuntimeLifecycleStatusDegraded + result.Reason = "install manifest does not match pinned bundle metadata" + return result, nil + } + if manifest.CommandPath != result.CommandPath || manifest.Root != bundleRoot { + result.Status = ManagedRuntimeLifecycleStatusDegraded + result.Reason = "install manifest path scope does not match scoped root" + return result, nil + } + if !manifest.ScopedStoreEnforced || !manifest.HostGlobalStoreExcluded { + result.Status = ManagedRuntimeLifecycleStatusDegraded + result.Reason = "scoped store policy is not enforced" + return result, nil + } + if err := managedRuntimeRequireFileUnderRoot(bundleRoot, result.CommandPath); err != nil { + result.Status = ManagedRuntimeLifecycleStatusMissing + result.Reason = err.Error() + return result, nil + } + if err := verifyManagedRuntimeInstalledFileDigests(bundleRoot, manifest.FileDigests); err != nil { + result.Status = ManagedRuntimeLifecycleStatusDegraded + result.Reason = err.Error() + return result, nil + } + return result, nil +} + +func (i ManagedRuntimeBundleInstaller) Uninstall(ctx context.Context, request ManagedRuntimeUninstallRequest) (ManagedRuntimeUninstallResult, error) { + if err := ctx.Err(); err != nil { + return ManagedRuntimeUninstallResult{}, err + } + _, bundleRoot, err := managedRuntimeBundleRoot(i.InstallRoot, request.BundleID) + if err != nil { + return ManagedRuntimeUninstallResult{}, err + } + installRoot := filepath.Dir(bundleRoot) + unlock, err := managedRuntimeAcquireBundleLock(installRoot, request.BundleID) + if err != nil { + return ManagedRuntimeUninstallResult{}, err + } + defer unlock() + _, statErr := os.Stat(bundleRoot) + removed := false + if statErr == nil { + if err := os.RemoveAll(bundleRoot); err != nil { + return ManagedRuntimeUninstallResult{}, err + } + removed = true + } else if !errors.Is(statErr, os.ErrNotExist) { + return ManagedRuntimeUninstallResult{}, statErr + } + return ManagedRuntimeUninstallResult{ + BundleID: request.BundleID, + Root: bundleRoot, + Removed: removed, + ScopedOnly: true, + ManifestPath: filepath.Join(bundleRoot, managedRuntimeInstallManifestName), + }, nil +} + +func (i ManagedRuntimeBundleInstaller) Reinstall(ctx context.Context, request ManagedRuntimeInstallRequest) (ManagedRuntimeReinstallResult, error) { + install, err := i.Install(ctx, request) + if err != nil { + return ManagedRuntimeReinstallResult{}, err + } + doctor, err := i.Doctor(ctx, ManagedRuntimeDoctorRequest{ + BundleID: request.BundleID, + TargetOS: request.TargetOS, + TargetArch: request.TargetArch, + }) + if err != nil { + return ManagedRuntimeReinstallResult{}, err + } + return ManagedRuntimeReinstallResult{ + Uninstall: ManagedRuntimeUninstallResult{ + BundleID: request.BundleID, + Root: install.Root, + Removed: true, + ScopedOnly: true, + ManifestPath: install.ManifestPath, + }, + Install: install, + Doctor: doctor, + }, nil +} + +func (i ManagedRuntimeBundleInstaller) fetchPinnedObjects(ctx context.Context, source ManagedRuntimeBundleObjectSource, bundle core.ManagedRuntimeBundleDescriptor) ([]byte, []byte, []byte, []byte, error) { + artifact, err := fetchManagedRuntimePinnedObject(ctx, i.Catalog, source, bundle, "artifact", bundle.ArtifactName, bundle.ArtifactDigest) + if err != nil { + return nil, nil, nil, nil, err + } + checksum, err := fetchManagedRuntimePinnedObject(ctx, i.Catalog, source, bundle, "checksum", bundle.ChecksumName, bundle.ChecksumDigest) + if err != nil { + return nil, nil, nil, nil, err + } + signature, err := fetchManagedRuntimePinnedObject(ctx, i.Catalog, source, bundle, "signature", bundle.SignatureName, bundle.SignatureDigest) + if err != nil { + return nil, nil, nil, nil, err + } + trustRoot, err := i.Catalog.TrustRootForBundle(bundle) + if err != nil { + return nil, nil, nil, nil, err + } + trustRootObject, err := fetchManagedRuntimePinnedObjectWithURL(ctx, source, bundle.BundleID, "trust root", trustRoot.Name, managedRuntimeTrustRootURL(i.Catalog, trustRoot), trustRoot.Digest) + if err != nil { + return nil, nil, nil, nil, err + } + return artifact, checksum, signature, trustRootObject, nil +} + +func (i ManagedRuntimeBundleInstaller) source() ManagedRuntimeBundleObjectSource { + if i.Source != nil { + return i.Source + } + return HTTPManagedRuntimeBundleObjectSource{Client: i.HTTPClient} +} + +func (i ManagedRuntimeBundleInstaller) now() time.Time { + if i.Now != nil { + if now := i.Now(); !now.IsZero() { + return now.UTC() + } + } + return time.Now().UTC() +} + +func fetchManagedRuntimePinnedObject(ctx context.Context, catalog ManagedRuntimeBundleCatalog, source ManagedRuntimeBundleObjectSource, bundle core.ManagedRuntimeBundleDescriptor, kind, name, wantDigest string) ([]byte, error) { + return fetchManagedRuntimePinnedObjectWithURL(ctx, source, bundle.BundleID, kind, name, managedRuntimeObjectURL(catalog.SourceBaseURL, name), wantDigest) +} + +func fetchManagedRuntimePinnedObjectWithURL(ctx context.Context, source ManagedRuntimeBundleObjectSource, bundleID, kind, name, url, wantDigest string) ([]byte, error) { + if strings.TrimSpace(name) == "" { + return nil, fmt.Errorf("managed runtime %s object name is required", kind) + } + if strings.TrimSpace(wantDigest) == "" { + return nil, fmt.Errorf("managed runtime %s digest is required", kind) + } + object, err := source.FetchManagedRuntimeBundleObject(ctx, ManagedRuntimeBundleObjectRequest{ + BundleID: bundleID, + Kind: kind, + Name: name, + URL: url, + }) + if err != nil { + return nil, fmt.Errorf("fetch managed runtime %s object: %w", kind, err) + } + if err := verifyManagedRuntimeDigest(kind, object, wantDigest); err != nil { + return nil, err + } + return object, nil +} + +func managedRuntimeTrustRootURL(catalog ManagedRuntimeBundleCatalog, root ManagedRuntimeTrustRoot) string { + if strings.TrimSpace(root.URL) != "" { + return strings.TrimSpace(root.URL) + } + return managedRuntimeObjectURL(catalog.SourceBaseURL, root.Name) +} + +func managedRuntimeObjectURL(base, name string) string { + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" { + return name + } + return base + "/" + name +} + +func verifyManagedRuntimeDigest(kind string, content []byte, want string) error { + want = strings.TrimSpace(want) + got := "sha256:" + managedRuntimeDigestHex(content) + if got != want { + return fmt.Errorf("managed runtime %s digest mismatch: got %s want %s", kind, got, want) + } + return nil +} + +func verifyManagedRuntimeChecksum(bundle core.ManagedRuntimeBundleDescriptor, checksum []byte) error { + wantDigest := strings.TrimPrefix(bundle.ArtifactDigest, "sha256:") + lines := bytes.Split(checksum, []byte{'\n'}) + for _, line := range lines { + fields := strings.Fields(string(line)) + if len(fields) < 2 { + continue + } + if fields[0] == wantDigest && fields[len(fields)-1] == bundle.ArtifactName { + return nil + } + } + return fmt.Errorf("managed runtime checksum object does not pin artifact %q to %s", bundle.ArtifactName, bundle.ArtifactDigest) +} + +func verifyManagedRuntimeSignature(bundle core.ManagedRuntimeBundleDescriptor, checksum, signature, trustRoot []byte) error { + if len(signature) == 0 { + return errors.New("managed runtime signature object is empty") + } + keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(trustRoot)) + if err != nil { + return fmt.Errorf("managed runtime signature verification trust root: %w", err) + } + signer, err := openpgp.CheckArmoredDetachedSignature(keyring, bytes.NewReader(checksum), bytes.NewReader(signature)) + if err != nil { + return fmt.Errorf("managed runtime signature verification failed: %w", err) + } + if signer == nil || signer.PrimaryKey == nil { + return errors.New("managed runtime signature verification did not identify a signer") + } + if !strings.EqualFold(signer.PrimaryKey.KeyIdString(), bundle.SignatureKeyID) { + return fmt.Errorf("managed runtime signature signer %q does not match pinned key %q", signer.PrimaryKey.KeyIdString(), bundle.SignatureKeyID) + } + return nil +} + +func managedRuntimeDigestHex(content []byte) string { + sum := sha256.Sum256(content) + return hex.EncodeToString(sum[:]) +} + +func managedRuntimeBundleRoot(installRoot, bundleID string) (string, string, error) { + if strings.TrimSpace(installRoot) == "" { + return "", "", errors.New("managed runtime install root is required") + } + if !managedRuntimeSafePathPart(bundleID) { + return "", "", fmt.Errorf("unsafe managed runtime bundle id %q", bundleID) + } + root, err := filepath.Abs(installRoot) + if err != nil { + return "", "", err + } + if err := managedRuntimeRejectSymlinkedInstallRootPath(root); err != nil { + return "", "", err + } + bundleRoot := filepath.Join(root, bundleID) + if err := managedRuntimeRequirePathUnderRoot(root, bundleRoot); err != nil { + return "", "", err + } + return root, bundleRoot, nil +} + +func managedRuntimeRejectSymlinkedInstallRootPath(root string) error { + root = filepath.Clean(root) + volume := filepath.VolumeName(root) + rest := strings.TrimPrefix(root, volume) + current := volume + if strings.HasPrefix(rest, string(os.PathSeparator)) { + current += string(os.PathSeparator) + rest = strings.TrimPrefix(rest, string(os.PathSeparator)) + } + for _, part := range strings.Split(rest, string(os.PathSeparator)) { + if part == "" || part == "." { + continue + } + if current == "" || current == string(os.PathSeparator) || strings.HasSuffix(current, string(os.PathSeparator)) { + current = filepath.Join(current, part) + } else { + current = filepath.Join(current, part) + } + info, err := os.Lstat(current) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("managed runtime install root path %q contains symlink %q", root, current) + } + } + return nil +} + +func managedRuntimeAcquireBundleLock(installRoot, bundleID string) (func(), error) { + lockRoot := filepath.Join(installRoot, ".locks") + if err := os.MkdirAll(lockRoot, 0o700); err != nil { + return nil, err + } + lockPath := filepath.Join(lockRoot, bundleID+".lock") + file, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) + if err != nil { + if errors.Is(err, os.ErrExist) { + return nil, fmt.Errorf("managed runtime bundle %q is already locked", bundleID) + } + return nil, err + } + _, _ = fmt.Fprintf(file, "%d\n", os.Getpid()) + _ = file.Close() + return func() { + _ = os.Remove(lockPath) + }, nil +} + +func managedRuntimePromoteStage(stageRoot, bundleRoot string) error { + backupRoot := bundleRoot + ".previous" + _ = os.RemoveAll(backupRoot) + hadExisting := false + if _, err := os.Stat(bundleRoot); err == nil { + hadExisting = true + if err := os.Rename(bundleRoot, backupRoot); err != nil { + return err + } + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + if err := os.Rename(stageRoot, bundleRoot); err != nil { + if hadExisting { + _ = os.Rename(backupRoot, bundleRoot) + } + return err + } + if hadExisting { + _ = os.RemoveAll(backupRoot) + } + return nil +} + +func managedRuntimeSafePathPart(value string) bool { + value = strings.TrimSpace(value) + if value == "" || value == "." || value == ".." { + return false + } + if filepath.IsAbs(value) || path.IsAbs(value) || filepath.VolumeName(value) != "" { + return false + } + if strings.Contains(value, "/") || strings.Contains(value, "\\") { + return false + } + return filepath.Clean(value) == value +} + +func managedRuntimeRequirePathUnderRoot(root, candidate string) error { + root, err := filepath.Abs(root) + if err != nil { + return err + } + candidate, err = filepath.Abs(candidate) + if err != nil { + return err + } + rel, err := filepath.Rel(root, candidate) + if err != nil { + return err + } + if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) { + return fmt.Errorf("managed runtime path %q escapes root %q", candidate, root) + } + return nil +} + +func managedRuntimeRequireFileUnderRoot(root, candidate string) error { + if err := managedRuntimeRequirePathUnderRoot(root, candidate); err != nil { + return err + } + if err := managedRuntimeRejectSymlinkPath(root, candidate); err != nil { + return err + } + info, err := os.Lstat(candidate) + if err != nil { + return err + } + if info.IsDir() { + return fmt.Errorf("%q is a directory", candidate) + } + return nil +} + +func managedRuntimeFileDigests(root, skipRel string) (map[string]string, error) { + out := map[string]string{} + err := filepath.WalkDir(root, func(pathValue string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if pathValue == root { + return nil + } + rel, err := filepath.Rel(root, pathValue) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if rel == skipRel { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("managed runtime path %q contains symlink", pathValue) + } + if entry.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return fmt.Errorf("managed runtime path %q is not a regular file", pathValue) + } + content, err := os.ReadFile(pathValue) + if err != nil { + return err + } + out[rel] = "sha256:" + managedRuntimeDigestHex(content) + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} + +func verifyManagedRuntimeInstalledFileDigests(root string, want map[string]string) error { + if len(want) == 0 { + return errors.New("managed runtime install manifest has no file digests") + } + got, err := managedRuntimeFileDigests(root, managedRuntimeInstallManifestName) + if err != nil { + return err + } + if len(got) != len(want) { + return fmt.Errorf("managed runtime file digest set changed: got %d want %d", len(got), len(want)) + } + for rel, wantDigest := range want { + if got[rel] != wantDigest { + return fmt.Errorf("managed runtime file digest mismatch for %s", rel) + } + } + return nil +} + +func managedRuntimeRejectSymlinkPath(root, candidate string) error { + root, err := filepath.Abs(root) + if err != nil { + return err + } + candidate, err = filepath.Abs(candidate) + if err != nil { + return err + } + rel, err := filepath.Rel(root, candidate) + if err != nil { + return err + } + current := root + for _, part := range strings.Split(rel, string(os.PathSeparator)) { + if part == "" || part == "." { + continue + } + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("managed runtime path %q contains symlink %q", candidate, current) + } + } + return nil +} + +func extractManagedRuntimeTarGzip(content []byte, dest string) error { + gz, err := gzip.NewReader(bytes.NewReader(content)) + if err != nil { + return err + } + defer gz.Close() + tr := tar.NewReader(gz) + for { + header, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + name, err := cleanManagedRuntimeArchivePath(header.Name) + if err != nil { + return err + } + target := filepath.Join(dest, filepath.FromSlash(name)) + if err := managedRuntimeRequirePathUnderRoot(dest, target); err != nil { + return err + } + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, modePerm(header.FileInfo().Mode())|0o700); err != nil { + return err + } + case tar.TypeReg, tar.TypeRegA: + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return err + } + mode := modePerm(header.FileInfo().Mode()) + if mode == 0 { + mode = 0o600 + } + file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + _, copyErr := io.Copy(file, tr) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + if closeErr != nil { + return closeErr + } + default: + return fmt.Errorf("unsafe archive entry type %d for %q", header.Typeflag, header.Name) + } + } +} + +func cleanManagedRuntimeArchivePath(name string) (string, error) { + if strings.TrimSpace(name) == "" || strings.Contains(name, "\\") || path.IsAbs(name) { + return "", fmt.Errorf("unsafe archive path %q", name) + } + clean := path.Clean(name) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("unsafe archive path %q", name) + } + return clean, nil +} + +func modePerm(mode os.FileMode) os.FileMode { + return mode.Perm() +} + +func writeManagedRuntimeManifest(path string, manifest managedRuntimeInstallManifest) error { + content, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + content = append(content, '\n') + return os.WriteFile(path, content, 0o600) +} + +func readManagedRuntimeManifest(path string) (managedRuntimeInstallManifest, error) { + content, err := os.ReadFile(path) + if err != nil { + return managedRuntimeInstallManifest{}, err + } + var manifest managedRuntimeInstallManifest + if err := json.Unmarshal(content, &manifest); err != nil { + return managedRuntimeInstallManifest{}, err + } + return manifest, nil +} diff --git a/container/managed_runtime_installer_test.go b/container/managed_runtime_installer_test.go new file mode 100644 index 0000000..055224d --- /dev/null +++ b/container/managed_runtime_installer_test.go @@ -0,0 +1,482 @@ +package container + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/armor" + "golang.org/x/crypto/openpgp/packet" +) + +func TestManagedRuntimeBundleInstallerLifecycleUsesScopedRootAndPinnedObjects(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + bundle := catalog.Bundles[0] + installRoot := realManagedRuntimeTestDir(t) + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: installRoot, + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + + installed, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: bundle.BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("install managed runtime bundle: %v", err) + } + if installed.BundleID != bundle.BundleID { + t.Fatalf("installed bundle id = %q, want %q", installed.BundleID, bundle.BundleID) + } + if !strings.HasPrefix(installed.Root, installRoot+string(os.PathSeparator)) { + t.Fatalf("installed root %q escaped install root %q", installed.Root, installRoot) + } + wantCommand := filepath.Join(installed.Root, "bin", "nerdctl") + if installed.CommandPath != wantCommand { + t.Fatalf("command path = %q, want %q", installed.CommandPath, wantCommand) + } + if _, err := os.Stat(installed.CommandPath); err != nil { + t.Fatalf("installed command missing: %v", err) + } + if _, err := os.Stat(installed.ManifestPath); err != nil { + t.Fatalf("install manifest missing: %v", err) + } + + doctor, err := installer.Doctor(ctx, ManagedRuntimeDoctorRequest{ + BundleID: bundle.BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("doctor managed runtime bundle: %v", err) + } + if doctor.Status != ManagedRuntimeLifecycleStatusOK { + t.Fatalf("doctor status = %q, want ok: %#v", doctor.Status, doctor) + } + if !doctor.ScopedStoreEnforced { + t.Fatalf("doctor did not preserve scoped store evidence: %#v", doctor) + } + + sibling := filepath.Join(installRoot, "sibling.txt") + if err := os.WriteFile(sibling, []byte("keep"), 0o600); err != nil { + t.Fatalf("write sibling: %v", err) + } + uninstalled, err := installer.Uninstall(ctx, ManagedRuntimeUninstallRequest{ + BundleID: bundle.BundleID, + }) + if err != nil { + t.Fatalf("uninstall managed runtime bundle: %v", err) + } + if !uninstalled.Removed { + t.Fatalf("uninstall removed = false, want true") + } + if _, err := os.Stat(installed.Root); !os.IsNotExist(err) { + t.Fatalf("installed root still exists or unexpected stat error: %v", err) + } + if _, err := os.Stat(sibling); err != nil { + t.Fatalf("uninstall removed install-root sibling: %v", err) + } + + reinstalled, err := installer.Reinstall(ctx, ManagedRuntimeInstallRequest{ + BundleID: bundle.BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("reinstall managed runtime bundle: %v", err) + } + if reinstalled.Install.CommandPath == "" || reinstalled.Doctor.Status != ManagedRuntimeLifecycleStatusOK { + t.Fatalf("reinstall did not finish with healthy doctor result: %#v", reinstalled) + } +} + +func TestManagedRuntimeBundleInstallerRejectsUnpinnedSignatureObject(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + objects[catalog.Bundles[0].SignatureName] = []byte("tampered signature") + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: realManagedRuntimeTestDir(t), + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + + _, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err == nil || !strings.Contains(err.Error(), "signature digest") { + t.Fatalf("install error = %v, want signature digest failure", err) + } +} + +func TestManagedRuntimeBundleInstallerRejectsDigestPinnedInvalidSignature(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + tampered := []byte("not a valid detached signature") + objects[catalog.Bundles[0].SignatureName] = tampered + catalog.Bundles[0].SignatureDigest = "sha256:" + testManagedRuntimeSHA256(tampered) + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: realManagedRuntimeTestDir(t), + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + + _, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err == nil || !strings.Contains(err.Error(), "signature verification") { + t.Fatalf("install error = %v, want signature verification failure", err) + } +} + +func TestManagedRuntimeBundleInstallerRejectsArchivePathEscape(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "../escape": "bad", + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + installRoot := realManagedRuntimeTestDir(t) + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: installRoot, + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + + _, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err == nil || !strings.Contains(err.Error(), "unsafe archive path") { + t.Fatalf("install error = %v, want unsafe archive path failure", err) + } + if _, err := os.Stat(filepath.Join(installRoot, "..", "escape")); !os.IsNotExist(err) { + t.Fatalf("archive escape was written or unexpected stat error: %v", err) + } +} + +func TestManagedRuntimeBundleInstallerRejectsSymlinkedInstallRoot(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + linkRoot := filepath.Join(t.TempDir(), "managed-runtime") + if err := os.Symlink(t.TempDir(), linkRoot); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: linkRoot, + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + + _, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("install error = %v, want symlink install root failure", err) + } +} + +func TestManagedRuntimeBundleInstallerRejectsSymlinkedInstallRootParent(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + base := t.TempDir() + parentLink := filepath.Join(base, "linked-parent") + if err := os.Symlink(t.TempDir(), parentLink); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: filepath.Join(parentLink, "managed-runtime"), + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + + _, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("install error = %v, want parent symlink install root failure", err) + } +} + +func TestManagedRuntimeBundleInstallerDoctorRejectsCommandSymlink(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: realManagedRuntimeTestDir(t), + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + installed, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("install managed runtime bundle: %v", err) + } + outside := filepath.Join(t.TempDir(), "nerdctl") + if err := os.WriteFile(outside, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write outside runtime: %v", err) + } + if err := os.Remove(installed.CommandPath); err != nil { + t.Fatalf("remove command: %v", err) + } + if err := os.Symlink(outside, installed.CommandPath); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + + doctor, err := installer.Doctor(ctx, ManagedRuntimeDoctorRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("doctor managed runtime bundle: %v", err) + } + if doctor.Status == ManagedRuntimeLifecycleStatusOK || !strings.Contains(doctor.Reason, "symlink") { + t.Fatalf("doctor = %#v, want degraded symlink result", doctor) + } +} + +func TestManagedRuntimeBundleInstallerDoctorRejectsTamperedInstalledContent(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: realManagedRuntimeTestDir(t), + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + installed, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("install managed runtime bundle: %v", err) + } + if err := os.WriteFile(installed.CommandPath, []byte("#!/bin/sh\nprintf tampered\n"), 0o755); err != nil { + t.Fatalf("tamper command: %v", err) + } + + doctor, err := installer.Doctor(ctx, ManagedRuntimeDoctorRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("doctor managed runtime bundle: %v", err) + } + if doctor.Status == ManagedRuntimeLifecycleStatusOK || !strings.Contains(doctor.Reason, "file digest") { + t.Fatalf("doctor = %#v, want file digest degradation", doctor) + } +} + +func TestManagedRuntimeBundleInstallerReinstallKeepsOldRuntimeWhenReplacementFetchFails(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + source := managedRuntimeTestSource(objects) + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: realManagedRuntimeTestDir(t), + Source: source, + Now: func() time.Time { return catalog.GeneratedAt }, + } + installed, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("install managed runtime bundle: %v", err) + } + objects[catalog.Bundles[0].ArtifactName] = []byte("bad replacement") + + _, err = installer.Reinstall(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err == nil { + t.Fatalf("reinstall error = nil, want replacement failure") + } + if _, statErr := os.Stat(installed.CommandPath); statErr != nil { + t.Fatalf("old command missing after failed reinstall: %v", statErr) + } + doctor, err := installer.Doctor(ctx, ManagedRuntimeDoctorRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("doctor old runtime: %v", err) + } + if doctor.Status != ManagedRuntimeLifecycleStatusOK { + t.Fatalf("old runtime doctor = %#v, want ok", doctor) + } +} + +func managedRuntimeTestSource(objects map[string][]byte) ManagedRuntimeBundleObjectSource { + return ManagedRuntimeBundleObjectSourceFunc(func(_ context.Context, request ManagedRuntimeBundleObjectRequest) ([]byte, error) { + object, ok := objects[request.Name] + if !ok { + return nil, fmt.Errorf("missing object %q", request.Name) + } + return bytes.Clone(object), nil + }) +} + +func realManagedRuntimeTestDir(t *testing.T) string { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("resolve temp dir: %v", err) + } + return dir +} + +func testManagedRuntimeCatalogAndObjects(t *testing.T, archiveFiles map[string]string) (ManagedRuntimeBundleCatalog, map[string][]byte) { + t.Helper() + entity, publicKey := testManagedRuntimeSigningEntity(t) + artifact := testManagedRuntimeTarGzip(t, archiveFiles) + artifactDigest := "sha256:" + testManagedRuntimeSHA256(artifact) + artifactName := "nerdctl-full-test-linux-amd64.tar.gz" + checksum := []byte(strings.TrimPrefix(artifactDigest, "sha256:") + " " + artifactName + "\n") + signature := testManagedRuntimeDetachedSignature(t, entity, checksum) + checksumDigest := "sha256:" + testManagedRuntimeSHA256(checksum) + signatureDigest := "sha256:" + testManagedRuntimeSHA256(signature) + trustRootName := "test-release-key.asc" + trustRootDigest := "sha256:" + testManagedRuntimeSHA256(publicKey) + keyID := entity.PrimaryKey.KeyIdString() + + catalog := validManagedRuntimeBundleCatalog() + catalog.SourceBaseURL = "memory://managed-runtime-test" + catalog.StableSigningKey = keyID + catalog.TrustRoots = []ManagedRuntimeTrustRoot{{ + KeyID: keyID, + Name: trustRootName, + Digest: trustRootDigest, + Issuer: "test release key", + }} + catalog.Bundles[0].ArtifactName = artifactName + catalog.Bundles[0].ArtifactDigest = artifactDigest + catalog.Bundles[0].ChecksumDigest = checksumDigest + catalog.Bundles[0].SignatureDigest = signatureDigest + catalog.Bundles[0].SignatureKeyID = keyID + catalog.Bundles[0].TrustRootDigest = trustRootDigest + catalog.Bundles[0].SignatureSubject.ArtifactDigest = artifactDigest + return catalog, map[string][]byte{ + artifactName: artifact, + catalog.Bundles[0].ChecksumName: checksum, + catalog.Bundles[0].SignatureName: signature, + trustRootName: publicKey, + } +} + +func testManagedRuntimeSigningEntity(t *testing.T) (*openpgp.Entity, []byte) { + t.Helper() + entity, err := openpgp.NewEntity("Workflow Compute Runtime Test", "managed-runtime", "runtime@example.invalid", &packet.Config{ + RSABits: 1024, + Time: func() time.Time { return time.Unix(1_700_000_000, 0).UTC() }, + }) + if err != nil { + t.Fatalf("create signing entity: %v", err) + } + var public bytes.Buffer + block, err := armor.Encode(&public, openpgp.PublicKeyType, nil) + if err != nil { + t.Fatalf("armor public key: %v", err) + } + if err := entity.Serialize(block); err != nil { + t.Fatalf("serialize public key: %v", err) + } + if err := block.Close(); err != nil { + t.Fatalf("close public key armor: %v", err) + } + return entity, public.Bytes() +} + +func testManagedRuntimeDetachedSignature(t *testing.T, entity *openpgp.Entity, content []byte) []byte { + t.Helper() + var signature bytes.Buffer + if err := openpgp.ArmoredDetachSign(&signature, entity, bytes.NewReader(content), &packet.Config{ + Time: func() time.Time { return time.Unix(1_700_000_001, 0).UTC() }, + }); err != nil { + t.Fatalf("sign checksum: %v", err) + } + return signature.Bytes() +} + +func testManagedRuntimeTarGzip(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for name, content := range files { + body := []byte(content) + if err := tw.WriteHeader(&tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(body)), + }); err != nil { + t.Fatalf("write tar header: %v", err) + } + if _, err := tw.Write(body); err != nil { + t.Fatalf("write tar body: %v", err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + return buf.Bytes() +} + +func testManagedRuntimeSHA256(content []byte) string { + sum := sha256.Sum256(content) + return fmt.Sprintf("%x", sum[:]) +} diff --git a/internal/managed_runtime_cli.go b/internal/managed_runtime_cli.go new file mode 100644 index 0000000..60cc537 --- /dev/null +++ b/internal/managed_runtime_cli.go @@ -0,0 +1,124 @@ +package internal + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + + "github.com/GoCodeAlone/workflow-plugin-compute-container/container" +) + +func RunManagedRuntimeCLI(ctx context.Context, args []string, stdout, stderr io.Writer) int { + if stdout == nil { + stdout = io.Discard + } + if stderr == nil { + stderr = io.Discard + } + if len(args) == 0 || args[0] != "managed-runtime" { + _, _ = fmt.Fprintln(stderr, "usage: managed-runtime [flags]") + return 2 + } + if len(args) < 2 { + _, _ = fmt.Fprintln(stderr, "managed-runtime subcommand is required") + return 2 + } + command := args[1] + cfg, ok := parseManagedRuntimeCLIFlags(command, args[2:], stderr) + if !ok { + return 2 + } + installer, err := cfg.installer() + if err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + var result any + switch command { + case "install": + result, err = installer.Install(ctx, cfg.installRequest()) + case "doctor": + result, err = installer.Doctor(ctx, cfg.doctorRequest()) + case "uninstall": + result, err = installer.Uninstall(ctx, container.ManagedRuntimeUninstallRequest{BundleID: cfg.BundleID}) + case "reinstall": + result, err = installer.Reinstall(ctx, cfg.installRequest()) + default: + _, _ = fmt.Fprintf(stderr, "unknown managed-runtime subcommand %q\n", command) + return 2 + } + if err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + if err := json.NewEncoder(stdout).Encode(result); err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + return 0 +} + +type managedRuntimeCLIConfig struct { + CatalogPath string + InstallRoot string + BundleID string + TargetOS string + TargetArch string +} + +func parseManagedRuntimeCLIFlags(command string, args []string, stderr io.Writer) (managedRuntimeCLIConfig, bool) { + fs := flag.NewFlagSet("managed-runtime "+command, flag.ContinueOnError) + fs.SetOutput(stderr) + cfg := managedRuntimeCLIConfig{} + fs.StringVar(&cfg.CatalogPath, "catalog", "", "managed runtime bundle catalog path") + fs.StringVar(&cfg.InstallRoot, "install-root", "", "agent-managed install root") + fs.StringVar(&cfg.BundleID, "bundle-id", "", "managed runtime bundle id") + fs.StringVar(&cfg.TargetOS, "target-os", "", "target operating system") + fs.StringVar(&cfg.TargetArch, "target-arch", "", "target architecture") + if err := fs.Parse(args); err != nil { + return managedRuntimeCLIConfig{}, false + } + if cfg.CatalogPath == "" || cfg.InstallRoot == "" || cfg.BundleID == "" { + _, _ = fmt.Fprintln(stderr, "--catalog, --install-root, and --bundle-id are required") + return managedRuntimeCLIConfig{}, false + } + if command != "uninstall" && (cfg.TargetOS == "" || cfg.TargetArch == "") { + _, _ = fmt.Fprintln(stderr, "--target-os and --target-arch are required") + return managedRuntimeCLIConfig{}, false + } + return cfg, true +} + +func (c managedRuntimeCLIConfig) installer() (container.ManagedRuntimeBundleInstaller, error) { + content, err := os.ReadFile(c.CatalogPath) + if err != nil { + return container.ManagedRuntimeBundleInstaller{}, fmt.Errorf("read managed runtime catalog: %w", err) + } + var catalog container.ManagedRuntimeBundleCatalog + if err := json.Unmarshal(content, &catalog); err != nil { + return container.ManagedRuntimeBundleInstaller{}, fmt.Errorf("decode managed runtime catalog: %w", err) + } + return container.ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: c.InstallRoot, + }, nil +} + +func (c managedRuntimeCLIConfig) installRequest() container.ManagedRuntimeInstallRequest { + return container.ManagedRuntimeInstallRequest{ + BundleID: c.BundleID, + TargetOS: c.TargetOS, + TargetArch: c.TargetArch, + } +} + +func (c managedRuntimeCLIConfig) doctorRequest() container.ManagedRuntimeDoctorRequest { + return container.ManagedRuntimeDoctorRequest{ + BundleID: c.BundleID, + TargetOS: c.TargetOS, + TargetArch: c.TargetArch, + } +} diff --git a/internal/managed_runtime_cli_test.go b/internal/managed_runtime_cli_test.go new file mode 100644 index 0000000..669a174 --- /dev/null +++ b/internal/managed_runtime_cli_test.go @@ -0,0 +1,234 @@ +package internal + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/GoCodeAlone/workflow-plugin-compute-container/container" + core "github.com/GoCodeAlone/workflow-plugin-compute-core/protocol" + "golang.org/x/crypto/openpgp" + "golang.org/x/crypto/openpgp/armor" + "golang.org/x/crypto/openpgp/packet" +) + +func TestManagedRuntimeCLIInstallDoctorUninstallLifecycle(t *testing.T) { + catalog, objects := testManagedRuntimeCLICatalogAndObjects(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + object, ok := objects[filepath.Base(r.URL.Path)] + if !ok { + http.NotFound(w, r) + return + } + _, _ = w.Write(object) + })) + defer server.Close() + catalog.SourceBaseURL = server.URL + catalogPath := filepath.Join(t.TempDir(), "managed-runtime-bundles.json") + content, err := json.Marshal(catalog) + if err != nil { + t.Fatalf("marshal catalog: %v", err) + } + if err := os.WriteFile(catalogPath, content, 0o600); err != nil { + t.Fatalf("write catalog: %v", err) + } + installRoot, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatalf("resolve install root: %v", err) + } + args := []string{ + "--catalog", catalogPath, + "--install-root", installRoot, + "--bundle-id", catalog.Bundles[0].BundleID, + "--target-os", "linux", + "--target-arch", "amd64", + } + + var stdout, stderr bytes.Buffer + if code := RunManagedRuntimeCLI(context.Background(), append([]string{"managed-runtime", "install"}, args...), &stdout, &stderr); code != 0 { + t.Fatalf("install exit code = %d stdout=%s stderr=%s", code, stdout.String(), stderr.String()) + } + var install container.ManagedRuntimeInstallResult + if err := json.Unmarshal(stdout.Bytes(), &install); err != nil { + t.Fatalf("decode install JSON: %v", err) + } + if install.CommandPath == "" { + t.Fatalf("install command path is empty: %#v", install) + } + + stdout.Reset() + if code := RunManagedRuntimeCLI(context.Background(), append([]string{"managed-runtime", "doctor"}, args...), &stdout, io.Discard); code != 0 { + t.Fatalf("doctor exit code = %d stdout=%s", code, stdout.String()) + } + var doctor container.ManagedRuntimeDoctorResult + if err := json.Unmarshal(stdout.Bytes(), &doctor); err != nil { + t.Fatalf("decode doctor JSON: %v", err) + } + if doctor.Status != container.ManagedRuntimeLifecycleStatusOK { + t.Fatalf("doctor status = %q, want ok: %#v", doctor.Status, doctor) + } + + stdout.Reset() + if code := RunManagedRuntimeCLI(context.Background(), append([]string{"managed-runtime", "uninstall"}, args...), &stdout, io.Discard); code != 0 { + t.Fatalf("uninstall exit code = %d stdout=%s", code, stdout.String()) + } + var uninstall container.ManagedRuntimeUninstallResult + if err := json.Unmarshal(stdout.Bytes(), &uninstall); err != nil { + t.Fatalf("decode uninstall JSON: %v", err) + } + if !uninstall.Removed || !uninstall.ScopedOnly { + t.Fatalf("uninstall result = %#v, want scoped removal", uninstall) + } +} + +func testManagedRuntimeCLICatalogAndObjects(t *testing.T) (container.ManagedRuntimeBundleCatalog, map[string][]byte) { + t.Helper() + entity, publicKey := testManagedRuntimeCLISigningEntity(t) + artifact := testManagedRuntimeCLITarGzip(t) + artifactDigest := "sha256:" + testManagedRuntimeCLISHA256(artifact) + artifactName := "nerdctl-full-test-linux-amd64.tar.gz" + checksum := []byte(testManagedRuntimeCLISHA256(artifact) + " " + artifactName + "\n") + signature := testManagedRuntimeCLIDetachedSignature(t, entity, checksum) + checksumDigest := "sha256:" + testManagedRuntimeCLISHA256(checksum) + signatureDigest := "sha256:" + testManagedRuntimeCLISHA256(signature) + trustRootName := "test-release-key.asc" + trustRootDigest := "sha256:" + testManagedRuntimeCLISHA256(publicKey) + keyID := entity.PrimaryKey.KeyIdString() + bundle := core.ManagedRuntimeBundleDescriptor{ + ProtocolVersion: core.Version, + BundleID: "managed-containerd-linux-amd64", + Family: core.RuntimeBackendFamilyContainerd, + Tool: core.ContainerRuntimeNerdctl, + Version: "v2.3.1", + OS: "linux", + Arch: "amd64", + ArtifactName: artifactName, + ArtifactDigest: artifactDigest, + ChecksumName: "SHA256SUMS", + ChecksumDigest: checksumDigest, + SignatureName: "SHA256SUMS.asc", + SignatureDigest: signatureDigest, + SignatureIssuer: "containerd/nerdctl release", + SignatureKeyID: keyID, + TrustRootDigest: trustRootDigest, + SignatureSubject: core.ManagedRuntimeSignatureSubject{ + ArtifactDigest: artifactDigest, + RuntimeFamily: core.RuntimeBackendFamilyContainerd, + OS: "linux", + Arch: "amd64", + Version: "v2.3.1", + Channel: "stable", + ConformanceProfile: "distroless-static-v1", + ScopedStorePolicyDigest: "sha256:311ab6244d878cf7280a5927f5af6063337ec262e35fd7c84c6579d07591337e", + }, + ValidUntil: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC), + UpdatePolicy: core.ManagedRuntimeUpdatePolicy{ + Channel: "stable", + MinSupportedVersion: "v2.3.1", + }, + CVEPolicy: core.ManagedRuntimeCVEPolicy{ + PolicyDigest: "sha256:5bc9d3baf40fe716e68bfc469c53040351288caaaa048650aeadd6320ca6d7c1", + UpdatedByVersion: "v2.3.1", + }, + ScopedStore: core.ManagedRuntimeScopedStorePolicy{ + Required: true, + NamespaceStrategy: "opaque-worker-pool-scope", + StoreStrategy: "workflow-owned-content-store", + PolicyDigest: "sha256:311ab6244d878cf7280a5927f5af6063337ec262e35fd7c84c6579d07591337e", + CleanupRequired: true, + HostGlobalVisibilityForbidden: true, + }, + SupportedTargets: []core.ManagedRuntimeTarget{{OS: "linux", Arch: "amd64"}}, + ConformanceProfile: "distroless-static-v1", + InstallBurden: core.RuntimeInstallBundled, + } + return container.ManagedRuntimeBundleCatalog{ + ReleaseTag: "v2.3.1", + GeneratedAt: time.Unix(1_700_000_000, 0).UTC(), + MinimumVersion: "v2.3.1", + StableSigningKey: keyID, + Bundles: []core.ManagedRuntimeBundleDescriptor{bundle}, + TrustRoots: []container.ManagedRuntimeTrustRoot{{ + KeyID: keyID, + Name: trustRootName, + Digest: trustRootDigest, + Issuer: "test release key", + }}, + }, map[string][]byte{ + artifactName: artifact, + "SHA256SUMS": checksum, + "SHA256SUMS.asc": signature, + trustRootName: publicKey, + } +} + +func testManagedRuntimeCLISigningEntity(t *testing.T) (*openpgp.Entity, []byte) { + t.Helper() + entity, err := openpgp.NewEntity("Workflow Compute Runtime Test", "managed-runtime", "runtime@example.invalid", &packet.Config{ + RSABits: 1024, + Time: func() time.Time { return time.Unix(1_700_000_000, 0).UTC() }, + }) + if err != nil { + t.Fatalf("create signing entity: %v", err) + } + var public bytes.Buffer + block, err := armor.Encode(&public, openpgp.PublicKeyType, nil) + if err != nil { + t.Fatalf("armor public key: %v", err) + } + if err := entity.Serialize(block); err != nil { + t.Fatalf("serialize public key: %v", err) + } + if err := block.Close(); err != nil { + t.Fatalf("close public key armor: %v", err) + } + return entity, public.Bytes() +} + +func testManagedRuntimeCLIDetachedSignature(t *testing.T, entity *openpgp.Entity, content []byte) []byte { + t.Helper() + var signature bytes.Buffer + if err := openpgp.ArmoredDetachSign(&signature, entity, bytes.NewReader(content), &packet.Config{ + Time: func() time.Time { return time.Unix(1_700_000_001, 0).UTC() }, + }); err != nil { + t.Fatalf("sign checksum: %v", err) + } + return signature.Bytes() +} + +func testManagedRuntimeCLITarGzip(t *testing.T) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + body := []byte("#!/bin/sh\nprintf nerdctl-test\n") + if err := tw.WriteHeader(&tar.Header{Name: "bin/nerdctl", Mode: 0o755, Size: int64(len(body))}); err != nil { + t.Fatalf("write tar header: %v", err) + } + if _, err := tw.Write(body); err != nil { + t.Fatalf("write tar body: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + return buf.Bytes() +} + +func testManagedRuntimeCLISHA256(content []byte) string { + sum := sha256.Sum256(content) + return fmt.Sprintf("%x", sum[:]) +} diff --git a/internal/plugin_test.go b/internal/plugin_test.go index 5c6b884..21b7644 100644 --- a/internal/plugin_test.go +++ b/internal/plugin_test.go @@ -182,6 +182,47 @@ func TestPluginContractsAdvertiseRuntimeBackendReports(t *testing.T) { t.Fatalf("RuntimeBackendReport protocol type not advertised: %+v", contracts.ProtocolTypes) } +func TestPluginContractsAdvertiseManagedRuntimeInstaller(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "plugin.contracts.json")) + if err != nil { + t.Fatal(err) + } + var contracts struct { + Contracts []struct { + Name string `json:"name"` + Wire string `json:"wire"` + GoType string `json:"goType"` + Commands []string `json:"commands"` + Produces []string `json:"produces"` + Guarantees []string `json:"guarantees"` + } `json:"contracts"` + } + if err := json.Unmarshal(data, &contracts); err != nil { + t.Fatal(err) + } + for _, contract := range contracts.Contracts { + if contract.Name != "ManagedRuntimeBundleInstaller" { + continue + } + if contract.Wire != "json" || + contract.GoType != "github.com/GoCodeAlone/workflow-plugin-compute-container/container.ManagedRuntimeBundleInstaller" || + !slices.Contains(contract.Commands, "managed-runtime install") || + !slices.Contains(contract.Commands, "managed-runtime doctor") || + !slices.Contains(contract.Commands, "managed-runtime uninstall") || + !slices.Contains(contract.Commands, "managed-runtime reinstall") || + !slices.Contains(contract.Produces, "ManagedRuntimeInstallResult") || + !slices.Contains(contract.Produces, "ManagedRuntimeDoctorResult") || + !slices.Contains(contract.Produces, "ManagedRuntimeUninstallResult") || + !slices.Contains(contract.Produces, "ManagedRuntimeReinstallResult") || + !slices.Contains(contract.Guarantees, "scoped-install-root") || + !slices.Contains(contract.Guarantees, "pinned-artifact-checksum-signature-digests") { + t.Fatalf("managed runtime installer contract incomplete: %+v", contract) + } + return + } + t.Fatalf("ManagedRuntimeBundleInstaller contract not advertised: %+v", contracts.Contracts) +} + func TestManagedRuntimeBundlePackagingScriptMatchesCatalogPins(t *testing.T) { script, err := os.ReadFile(filepath.Join("..", "scripts", "package-managed-runtime-bundle.sh")) if err != nil { @@ -193,6 +234,8 @@ func TestManagedRuntimeBundlePackagingScriptMatchesCatalogPins(t *testing.T) { "artifact_digest=\"7a0d8efcf55b10b57d831541266adb9c6ec3d55b44ec041c95f6eb994d1faab9\"", "checksum_digest=\"8a0586ff11d4d5a5d19d59494a10af8c6d41dd95ca72ff347f62d5288bc5131a\"", "signature_digest=\"f87400e0923e22eab251328bd210bb9e8d3bba2b58dbbb84699622474344d68c\"", + "trust_root_url=\"https://github.com/ChengyuZhu6.gpg\"", + "trust_root_digest=\"812ecad48a498fe3fbda46805da3fb2e98328531e3aac3e171a63c9daa1d9206\"", "grep -F \"${artifact_digest} ${artifact_name}\"", } { if !strings.Contains(string(script), want) { diff --git a/managed-runtime-bundles.json b/managed-runtime-bundles.json index 8a5d4b9..fdf3576 100644 --- a/managed-runtime-bundles.json +++ b/managed-runtime-bundles.json @@ -3,7 +3,7 @@ "source_base_url": "https://github.com/containerd/nerdctl/releases/download/v2.3.1", "generated_at": "2026-06-11T00:00:00Z", "minimum_version": "v2.3.1", - "stable_signing_key": "containerd-nerdctl-release", + "stable_signing_key": "E14F08B8908ED6E8", "bundles": [ { "protocol_version": "compute.v1alpha1", @@ -20,8 +20,8 @@ "signature_name": "SHA256SUMS.asc", "signature_digest": "sha256:f87400e0923e22eab251328bd210bb9e8d3bba2b58dbbb84699622474344d68c", "signature_issuer": "containerd/nerdctl release", - "signature_key_id": "containerd-nerdctl-release", - "trust_root_digest": "sha256:6fad18923304aba73378965a8bac49bf44a3a22da73df42ca6a081c726c36b34", + "signature_key_id": "E14F08B8908ED6E8", + "trust_root_digest": "sha256:812ecad48a498fe3fbda46805da3fb2e98328531e3aac3e171a63c9daa1d9206", "signature_subject": { "artifact_digest": "sha256:7a0d8efcf55b10b57d831541266adb9c6ec3d55b44ec041c95f6eb994d1faab9", "runtime_family": "containerd", @@ -58,5 +58,14 @@ "conformance_profile": "distroless-static-v1", "install_burden": "bundled" } + ], + "trust_roots": [ + { + "key_id": "E14F08B8908ED6E8", + "name": "ChengyuZhu6.gpg", + "url": "https://github.com/ChengyuZhu6.gpg", + "digest": "sha256:812ecad48a498fe3fbda46805da3fb2e98328531e3aac3e171a63c9daa1d9206", + "issuer": "Chengyu Zhu " + } ] } diff --git a/plugin.contracts.json b/plugin.contracts.json index adbb358..93f8257 100644 --- a/plugin.contracts.json +++ b/plugin.contracts.json @@ -1,6 +1,30 @@ { "version": "1", - "contracts": [], + "contracts": [ + { + "name": "ManagedRuntimeBundleInstaller", + "wire": "json", + "goType": "github.com/GoCodeAlone/workflow-plugin-compute-container/container.ManagedRuntimeBundleInstaller", + "commands": [ + "managed-runtime install", + "managed-runtime doctor", + "managed-runtime uninstall", + "managed-runtime reinstall" + ], + "produces": [ + "ManagedRuntimeInstallResult", + "ManagedRuntimeDoctorResult", + "ManagedRuntimeUninstallResult", + "ManagedRuntimeReinstallResult" + ], + "guarantees": [ + "scoped-install-root", + "pinned-artifact-checksum-signature-digests", + "safe-tar-gzip-extraction", + "scoped-uninstall-only" + ] + } + ], "protocolTypes": [ { "name": "RuntimeBackendReport", diff --git a/scripts/package-managed-runtime-bundle.sh b/scripts/package-managed-runtime-bundle.sh index 271bed9..ea6325b 100755 --- a/scripts/package-managed-runtime-bundle.sh +++ b/scripts/package-managed-runtime-bundle.sh @@ -15,6 +15,9 @@ artifact_name="nerdctl-full-2.3.1-linux-amd64.tar.gz" artifact_digest="7a0d8efcf55b10b57d831541266adb9c6ec3d55b44ec041c95f6eb994d1faab9" checksum_digest="8a0586ff11d4d5a5d19d59494a10af8c6d41dd95ca72ff347f62d5288bc5131a" signature_digest="f87400e0923e22eab251328bd210bb9e8d3bba2b58dbbb84699622474344d68c" +trust_root_name="ChengyuZhu6.gpg" +trust_root_url="https://github.com/ChengyuZhu6.gpg" +trust_root_digest="812ecad48a498fe3fbda46805da3fb2e98328531e3aac3e171a63c9daa1d9206" manifest="${out_dir}/${bundle_id}.source.json" sha256_file() { @@ -40,8 +43,10 @@ rm -rf "$out_dir" mkdir -p "$out_dir" curl -fsSL -o "${out_dir}/SHA256SUMS" "${source_base_url}/SHA256SUMS" curl -fsSL -o "${out_dir}/SHA256SUMS.asc" "${source_base_url}/SHA256SUMS.asc" +curl -fsSL -o "${out_dir}/${trust_root_name}" "${trust_root_url}" require_digest "${out_dir}/SHA256SUMS" "$checksum_digest" require_digest "${out_dir}/SHA256SUMS.asc" "$signature_digest" +require_digest "${out_dir}/${trust_root_name}" "$trust_root_digest" if ! grep -F "${artifact_digest} ${artifact_name}" "${out_dir}/SHA256SUMS" >/dev/null; then printf 'managed runtime artifact %s with digest %s not found in SHA256SUMS\n' "$artifact_name" "$artifact_digest" >&2 exit 1 @@ -56,7 +61,10 @@ cat > "$manifest" < "${manifest}.sha256" From 47d15cc313e2dc087700d65180f13c45b81c829f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 11 Jun 2026 21:05:06 -0400 Subject: [PATCH 3/6] fix: harden managed runtime installer --- container/managed_runtime_installer.go | 41 ++++++++++++++------- container/managed_runtime_installer_test.go | 12 +++++- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/container/managed_runtime_installer.go b/container/managed_runtime_installer.go index ed1cd2a..e16d2b3 100644 --- a/container/managed_runtime_installer.go +++ b/container/managed_runtime_installer.go @@ -23,6 +23,7 @@ import ( ) const managedRuntimeInstallManifestName = "wfcompute-managed-runtime-install.json" +const defaultManagedRuntimeHTTPTimeout = 30 * time.Second type ManagedRuntimeLifecycleStatus string @@ -81,15 +82,11 @@ func (s HTTPManagedRuntimeBundleObjectSource) FetchManagedRuntimeBundleObject(ct if strings.TrimSpace(request.URL) == "" { return nil, errors.New("managed runtime object URL is required") } - client := s.Client - if client == nil { - client = http.DefaultClient - } httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, request.URL, nil) if err != nil { return nil, err } - resp, err := client.Do(httpReq) + resp, err := s.httpClient().Do(httpReq) if err != nil { return nil, err } @@ -100,6 +97,13 @@ func (s HTTPManagedRuntimeBundleObjectSource) FetchManagedRuntimeBundleObject(ct return io.ReadAll(resp.Body) } +func (s HTTPManagedRuntimeBundleObjectSource) httpClient() *http.Client { + if s.Client != nil { + return s.Client + } + return &http.Client{Timeout: defaultManagedRuntimeHTTPTimeout} +} + type ManagedRuntimeInstallResult struct { BundleID string `json:"bundle_id"` Version string `json:"version"` @@ -326,7 +330,11 @@ func (i ManagedRuntimeBundleInstaller) Doctor(ctx context.Context, request Manag return result, nil } if err := managedRuntimeRequireFileUnderRoot(bundleRoot, result.CommandPath); err != nil { - result.Status = ManagedRuntimeLifecycleStatusMissing + if errors.Is(err, os.ErrNotExist) { + result.Status = ManagedRuntimeLifecycleStatusMissing + } else { + result.Status = ManagedRuntimeLifecycleStatusDegraded + } result.Reason = err.Error() return result, nil } @@ -782,14 +790,10 @@ func extractManagedRuntimeTarGzip(content []byte, dest string) error { if err != nil { return err } - name, err := cleanManagedRuntimeArchivePath(header.Name) + target, err := managedRuntimeArchiveTarget(dest, header.Name) if err != nil { return err } - target := filepath.Join(dest, filepath.FromSlash(name)) - if err := managedRuntimeRequirePathUnderRoot(dest, target); err != nil { - return err - } switch header.Typeflag { case tar.TypeDir: if err := os.MkdirAll(target, modePerm(header.FileInfo().Mode())|0o700); err != nil { @@ -821,7 +825,7 @@ func extractManagedRuntimeTarGzip(content []byte, dest string) error { } } -func cleanManagedRuntimeArchivePath(name string) (string, error) { +func managedRuntimeArchiveTarget(dest, name string) (string, error) { if strings.TrimSpace(name) == "" || strings.Contains(name, "\\") || path.IsAbs(name) { return "", fmt.Errorf("unsafe archive path %q", name) } @@ -829,7 +833,18 @@ func cleanManagedRuntimeArchivePath(name string) (string, error) { if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { return "", fmt.Errorf("unsafe archive path %q", name) } - return clean, nil + local, err := filepath.Localize(clean) + if err != nil { + return "", fmt.Errorf("unsafe archive path %q: %w", name, err) + } + if !filepath.IsLocal(local) { + return "", fmt.Errorf("unsafe archive path %q", name) + } + target := filepath.Join(dest, local) + if err := managedRuntimeRequirePathUnderRoot(dest, target); err != nil { + return "", err + } + return target, nil } func modePerm(mode os.FileMode) os.FileMode { diff --git a/container/managed_runtime_installer_test.go b/container/managed_runtime_installer_test.go index 055224d..ad90e15 100644 --- a/container/managed_runtime_installer_test.go +++ b/container/managed_runtime_installer_test.go @@ -271,11 +271,21 @@ func TestManagedRuntimeBundleInstallerDoctorRejectsCommandSymlink(t *testing.T) if err != nil { t.Fatalf("doctor managed runtime bundle: %v", err) } - if doctor.Status == ManagedRuntimeLifecycleStatusOK || !strings.Contains(doctor.Reason, "symlink") { + if doctor.Status != ManagedRuntimeLifecycleStatusDegraded || !strings.Contains(doctor.Reason, "symlink") { t.Fatalf("doctor = %#v, want degraded symlink result", doctor) } } +func TestHTTPManagedRuntimeBundleObjectSourceDefaultClientHasTimeout(t *testing.T) { + source := HTTPManagedRuntimeBundleObjectSource{} + + client := source.httpClient() + + if client.Timeout <= 0 { + t.Fatalf("default managed runtime HTTP client timeout = %s, want positive timeout", client.Timeout) + } +} + func TestManagedRuntimeBundleInstallerDoctorRejectsTamperedInstalledContent(t *testing.T) { ctx := context.Background() catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ From 581b2185c203a14c814763939cf0ff6d925fcb00 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 11 Jun 2026 21:07:41 -0400 Subject: [PATCH 4/6] fix: extract runtime bundles through root handle --- container/managed_runtime_installer.go | 28 ++++++++++++-------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/container/managed_runtime_installer.go b/container/managed_runtime_installer.go index e16d2b3..5ec6c18 100644 --- a/container/managed_runtime_installer.go +++ b/container/managed_runtime_installer.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "io" + "io/fs" "net/http" "os" "path" @@ -781,6 +782,11 @@ func extractManagedRuntimeTarGzip(content []byte, dest string) error { return err } defer gz.Close() + root, err := os.OpenRoot(dest) + if err != nil { + return err + } + defer root.Close() tr := tar.NewReader(gz) for { header, err := tr.Next() @@ -790,24 +796,24 @@ func extractManagedRuntimeTarGzip(content []byte, dest string) error { if err != nil { return err } - target, err := managedRuntimeArchiveTarget(dest, header.Name) + name, err := managedRuntimeArchiveLocalPath(header.Name) if err != nil { return err } switch header.Typeflag { case tar.TypeDir: - if err := os.MkdirAll(target, modePerm(header.FileInfo().Mode())|0o700); err != nil { + if err := root.MkdirAll(name, modePerm(header.FileInfo().Mode())|0o700); err != nil { return err } case tar.TypeReg, tar.TypeRegA: - if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + if err := root.MkdirAll(path.Dir(name), 0o700); err != nil { return err } mode := modePerm(header.FileInfo().Mode()) if mode == 0 { mode = 0o600 } - file, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + file, err := root.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) if err != nil { return err } @@ -825,7 +831,7 @@ func extractManagedRuntimeTarGzip(content []byte, dest string) error { } } -func managedRuntimeArchiveTarget(dest, name string) (string, error) { +func managedRuntimeArchiveLocalPath(name string) (string, error) { if strings.TrimSpace(name) == "" || strings.Contains(name, "\\") || path.IsAbs(name) { return "", fmt.Errorf("unsafe archive path %q", name) } @@ -833,18 +839,10 @@ func managedRuntimeArchiveTarget(dest, name string) (string, error) { if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { return "", fmt.Errorf("unsafe archive path %q", name) } - local, err := filepath.Localize(clean) - if err != nil { - return "", fmt.Errorf("unsafe archive path %q: %w", name, err) - } - if !filepath.IsLocal(local) { + if !fs.ValidPath(clean) { return "", fmt.Errorf("unsafe archive path %q", name) } - target := filepath.Join(dest, local) - if err := managedRuntimeRequirePathUnderRoot(dest, target); err != nil { - return "", err - } - return target, nil + return clean, nil } func modePerm(mode os.FileMode) os.FileMode { From 40d03403fd3c04cc3a0edf249e8b1d425bb0c6ab Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 11 Jun 2026 21:13:06 -0400 Subject: [PATCH 5/6] fix: tighten runtime lifecycle contract --- container/managed_runtime_installer.go | 39 +++++++- container/managed_runtime_installer_test.go | 98 +++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) diff --git a/container/managed_runtime_installer.go b/container/managed_runtime_installer.go index 5ec6c18..5ec4472 100644 --- a/container/managed_runtime_installer.go +++ b/container/managed_runtime_installer.go @@ -25,6 +25,7 @@ import ( const managedRuntimeInstallManifestName = "wfcompute-managed-runtime-install.json" const defaultManagedRuntimeHTTPTimeout = 30 * time.Second +const defaultManagedRuntimeHTTPMaxBytes = 512 << 20 type ManagedRuntimeLifecycleStatus string @@ -95,7 +96,15 @@ func (s HTTPManagedRuntimeBundleObjectSource) FetchManagedRuntimeBundleObject(ct if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("fetch managed runtime object %q: %s", request.Name, resp.Status) } - return io.ReadAll(resp.Body) + limited := io.LimitReader(resp.Body, defaultManagedRuntimeHTTPMaxBytes+1) + content, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if int64(len(content)) > defaultManagedRuntimeHTTPMaxBytes { + return nil, fmt.Errorf("managed runtime object %q is too large", request.Name) + } + return content, nil } func (s HTTPManagedRuntimeBundleObjectSource) httpClient() *http.Client { @@ -240,6 +249,7 @@ func (i ManagedRuntimeBundleInstaller) Install(ctx context.Context, request Mana TrustRootDigest: bundle.TrustRootDigest, ScopedStoreEnforced: bundle.ScopedStore.Required, HostGlobalStoreExcluded: bundle.ScopedStore.HostGlobalVisibilityForbidden, + FileDigests: fileDigests, InstalledAt: now, } manifest := managedRuntimeInstallManifest{ @@ -364,6 +374,9 @@ func (i ManagedRuntimeBundleInstaller) Uninstall(ctx context.Context, request Ma _, statErr := os.Stat(bundleRoot) removed := false if statErr == nil { + if err := i.validateManagedRuntimeManifestForRemoval(bundleRoot, request.BundleID); err != nil { + return ManagedRuntimeUninstallResult{}, err + } if err := os.RemoveAll(bundleRoot); err != nil { return ManagedRuntimeUninstallResult{}, err } @@ -381,6 +394,16 @@ func (i ManagedRuntimeBundleInstaller) Uninstall(ctx context.Context, request Ma } func (i ManagedRuntimeBundleInstaller) Reinstall(ctx context.Context, request ManagedRuntimeInstallRequest) (ManagedRuntimeReinstallResult, error) { + _, bundleRoot, err := managedRuntimeBundleRoot(i.InstallRoot, request.BundleID) + if err != nil { + return ManagedRuntimeReinstallResult{}, err + } + replaced := false + if _, err := os.Stat(bundleRoot); err == nil { + replaced = true + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return ManagedRuntimeReinstallResult{}, err + } install, err := i.Install(ctx, request) if err != nil { return ManagedRuntimeReinstallResult{}, err @@ -397,7 +420,7 @@ func (i ManagedRuntimeBundleInstaller) Reinstall(ctx context.Context, request Ma Uninstall: ManagedRuntimeUninstallResult{ BundleID: request.BundleID, Root: install.Root, - Removed: true, + Removed: replaced, ScopedOnly: true, ManifestPath: install.ManifestPath, }, @@ -406,6 +429,18 @@ func (i ManagedRuntimeBundleInstaller) Reinstall(ctx context.Context, request Ma }, nil } +func (i ManagedRuntimeBundleInstaller) validateManagedRuntimeManifestForRemoval(bundleRoot, bundleID string) error { + manifestPath := filepath.Join(bundleRoot, managedRuntimeInstallManifestName) + manifest, err := readManagedRuntimeManifest(manifestPath) + if err != nil { + return fmt.Errorf("managed runtime install manifest is required before uninstall: %w", err) + } + if manifest.BundleID != bundleID || manifest.Root != bundleRoot || manifest.CommandPath != filepath.Join(bundleRoot, "bin", "nerdctl") { + return errors.New("managed runtime install manifest does not match scoped bundle root") + } + return nil +} + func (i ManagedRuntimeBundleInstaller) fetchPinnedObjects(ctx context.Context, source ManagedRuntimeBundleObjectSource, bundle core.ManagedRuntimeBundleDescriptor) ([]byte, []byte, []byte, []byte, error) { artifact, err := fetchManagedRuntimePinnedObject(ctx, i.Catalog, source, bundle, "artifact", bundle.ArtifactName, bundle.ArtifactDigest) if err != nil { diff --git a/container/managed_runtime_installer_test.go b/container/managed_runtime_installer_test.go index ad90e15..1b85657 100644 --- a/container/managed_runtime_installer_test.go +++ b/container/managed_runtime_installer_test.go @@ -7,6 +7,9 @@ import ( "context" "crypto/sha256" "fmt" + "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -50,6 +53,9 @@ func TestManagedRuntimeBundleInstallerLifecycleUsesScopedRootAndPinnedObjects(t if installed.CommandPath != wantCommand { t.Fatalf("command path = %q, want %q", installed.CommandPath, wantCommand) } + if installed.FileDigests["bin/nerdctl"] == "" { + t.Fatalf("install result missing file digests: %#v", installed.FileDigests) + } if _, err := os.Stat(installed.CommandPath); err != nil { t.Fatalf("installed command missing: %v", err) } @@ -368,6 +374,89 @@ func TestManagedRuntimeBundleInstallerReinstallKeepsOldRuntimeWhenReplacementFet } } +func TestManagedRuntimeBundleInstallerReinstallReportsWhetherPriorRuntimeWasReplaced(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: realManagedRuntimeTestDir(t), + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + + first, err := installer.Reinstall(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("first reinstall: %v", err) + } + if first.Uninstall.Removed { + t.Fatalf("first reinstall removed = true, want false for no prior runtime: %#v", first.Uninstall) + } + second, err := installer.Reinstall(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err != nil { + t.Fatalf("second reinstall: %v", err) + } + if !second.Uninstall.Removed { + t.Fatalf("second reinstall removed = false, want true for replacement: %#v", second.Uninstall) + } +} + +func TestManagedRuntimeBundleInstallerUninstallRejectsUnmanagedDirectory(t *testing.T) { + ctx := context.Background() + catalog, _ := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + installRoot := realManagedRuntimeTestDir(t) + unmanaged := filepath.Join(installRoot, catalog.Bundles[0].BundleID) + if err := os.MkdirAll(unmanaged, 0o700); err != nil { + t.Fatalf("write unmanaged root: %v", err) + } + sentinel := filepath.Join(unmanaged, "sentinel.txt") + if err := os.WriteFile(sentinel, []byte("keep"), 0o600); err != nil { + t.Fatalf("write unmanaged sentinel: %v", err) + } + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: installRoot, + Now: func() time.Time { return catalog.GeneratedAt }, + } + + _, err := installer.Uninstall(ctx, ManagedRuntimeUninstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + }) + if err == nil || !strings.Contains(err.Error(), "install manifest") { + t.Fatalf("uninstall error = %v, want unmanaged manifest failure", err) + } + if _, statErr := os.Stat(sentinel); statErr != nil { + t.Fatalf("unmanaged sentinel removed: %v", statErr) + } +} + +func TestHTTPManagedRuntimeBundleObjectSourceRejectsOversizedObject(t *testing.T) { + source := HTTPManagedRuntimeBundleObjectSource{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(w, io.LimitReader(zeroReader{}, defaultManagedRuntimeHTTPMaxBytes+1)) + })) + defer server.Close() + + _, err := source.FetchManagedRuntimeBundleObject(context.Background(), ManagedRuntimeBundleObjectRequest{ + Name: "oversized", + URL: server.URL, + }) + if err == nil || !strings.Contains(err.Error(), "too large") { + t.Fatalf("fetch error = %v, want oversized object failure", err) + } +} + func managedRuntimeTestSource(objects map[string][]byte) ManagedRuntimeBundleObjectSource { return ManagedRuntimeBundleObjectSourceFunc(func(_ context.Context, request ManagedRuntimeBundleObjectRequest) ([]byte, error) { object, ok := objects[request.Name] @@ -378,6 +467,15 @@ func managedRuntimeTestSource(objects map[string][]byte) ManagedRuntimeBundleObj }) } +type zeroReader struct{} + +func (zeroReader) Read(p []byte) (int, error) { + for i := range p { + p[i] = 0 + } + return len(p), nil +} + func realManagedRuntimeTestDir(t *testing.T) string { t.Helper() dir, err := filepath.EvalSymlinks(t.TempDir()) From 319ac9061704df184a050352f371c3f0415ef2ec Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Thu, 11 Jun 2026 21:16:43 -0400 Subject: [PATCH 6/6] fix: preserve unmanaged runtime directories --- container/managed_runtime_installer.go | 17 +++++++++++ container/managed_runtime_installer_test.go | 34 +++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/container/managed_runtime_installer.go b/container/managed_runtime_installer.go index 5ec4472..724d67f 100644 --- a/container/managed_runtime_installer.go +++ b/container/managed_runtime_installer.go @@ -198,6 +198,9 @@ func (i ManagedRuntimeBundleInstaller) Install(ctx context.Context, request Mana return ManagedRuntimeInstallResult{}, err } defer unlock() + if err := i.validateManagedRuntimeExistingRootForReplacement(bundleRoot, bundle.BundleID); err != nil { + return ManagedRuntimeInstallResult{}, err + } source := i.source() artifact, checksum, signature, trustRoot, err := i.fetchPinnedObjects(ctx, source, bundle) if err != nil { @@ -441,6 +444,20 @@ func (i ManagedRuntimeBundleInstaller) validateManagedRuntimeManifestForRemoval( return nil } +func (i ManagedRuntimeBundleInstaller) validateManagedRuntimeExistingRootForReplacement(bundleRoot, bundleID string) error { + _, err := os.Stat(bundleRoot) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if err := i.validateManagedRuntimeManifestForRemoval(bundleRoot, bundleID); err != nil { + return fmt.Errorf("managed runtime install manifest is required before replacement: %w", err) + } + return nil +} + func (i ManagedRuntimeBundleInstaller) fetchPinnedObjects(ctx context.Context, source ManagedRuntimeBundleObjectSource, bundle core.ManagedRuntimeBundleDescriptor) ([]byte, []byte, []byte, []byte, error) { artifact, err := fetchManagedRuntimePinnedObject(ctx, i.Catalog, source, bundle, "artifact", bundle.ArtifactName, bundle.ArtifactDigest) if err != nil { diff --git a/container/managed_runtime_installer_test.go b/container/managed_runtime_installer_test.go index 1b85657..9b84a3e 100644 --- a/container/managed_runtime_installer_test.go +++ b/container/managed_runtime_installer_test.go @@ -441,6 +441,40 @@ func TestManagedRuntimeBundleInstallerUninstallRejectsUnmanagedDirectory(t *test } } +func TestManagedRuntimeBundleInstallerInstallRejectsUnmanagedExistingDirectory(t *testing.T) { + ctx := context.Background() + catalog, objects := testManagedRuntimeCatalogAndObjects(t, map[string]string{ + "bin/nerdctl": "#!/bin/sh\nprintf nerdctl-test\n", + }) + installRoot := realManagedRuntimeTestDir(t) + unmanaged := filepath.Join(installRoot, catalog.Bundles[0].BundleID) + if err := os.MkdirAll(unmanaged, 0o700); err != nil { + t.Fatalf("write unmanaged root: %v", err) + } + sentinel := filepath.Join(unmanaged, "sentinel.txt") + if err := os.WriteFile(sentinel, []byte("keep"), 0o600); err != nil { + t.Fatalf("write unmanaged sentinel: %v", err) + } + installer := ManagedRuntimeBundleInstaller{ + Catalog: catalog, + InstallRoot: installRoot, + Source: managedRuntimeTestSource(objects), + Now: func() time.Time { return catalog.GeneratedAt }, + } + + _, err := installer.Install(ctx, ManagedRuntimeInstallRequest{ + BundleID: catalog.Bundles[0].BundleID, + TargetOS: "linux", + TargetArch: "amd64", + }) + if err == nil || !strings.Contains(err.Error(), "install manifest") { + t.Fatalf("install error = %v, want unmanaged manifest failure", err) + } + if _, statErr := os.Stat(sentinel); statErr != nil { + t.Fatalf("unmanaged sentinel removed: %v", statErr) + } +} + func TestHTTPManagedRuntimeBundleObjectSourceRejectsOversizedObject(t *testing.T) { source := HTTPManagedRuntimeBundleObjectSource{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {