Repository: kubernetes-sigs/security-profiles-operator
Pull request #2201 (2024-04-09 15:31:58)

Add `spoc convert` Command
#### What type of PR is this?

/kind bug
/kind feature

#### What this PR does / why we need it:

This command allows users to transform security profiles from a custom resource definition YAML to the raw profile as consumed by the respective security module. My primary use case here is testing AppArmor rules without dealing with Kubernetes, but it works for seccomp just as well.

#### Which issue(s) this PR fixes:

None

#### Does this PR have test?

Yes.

#### Special notes for your reviewer:

This PR first consolidates the code paths in `artifact` and `merger` to read YAML files (fixing ab bug in the artifact impl, see https://github.com/kubernetes-sigs/security-profiles-operator/pull/2136#discussion_r1504981477), and then reuses that for the convert functionality. It's probably easiest to review commits individually.

Happy to bikeshed on the name of the command if you feel `convert` is too generic. I hold no strong opinions. :)

#### Does this PR introduce a user-facing change?

<!--
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".

For more information on release notes see: https://git.k8s.io/community/contributors/guide/release-notes.md
-->

```release-note
Added a `spoc convert` command to transform security profile YAML definitions to their raw representation.
```


k8s-ci-robot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/security-profiles-operator/pull/2201#" title="Author self-approved">mhils</a>*
**Once this PR has been reviewed and has the lgtm label**, please assign [pjbgf](https://github.com/pjbgf) for approval. For more information see [the Kubernetes Code Review Process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process).

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fsecurity-profiles-operator).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/kubernetes-sigs/security-profiles-operator/blob/main/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["pjbgf"]} -->
codecov-commenter: ## [Codecov](https://app.codecov.io/gh/kubernetes-sigs/security-profiles-operator/pull/2201?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs) Report
> Merging [#2201](https://app.codecov.io/gh/kubernetes-sigs/security-profiles-operator/pull/2201?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs) (a0ea9ab) into [main](https://app.codecov.io/gh/kubernetes-sigs/security-profiles-operator/commit/11d77f49d652b2b077eaeb9a0ef9eff5f24b6a8d?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs) (11d77f4) will **decrease** coverage by `4.37%`.
> Report is 139 commits behind head on main.
> The diff coverage is `56.61%`.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2201      +/-   ##
==========================================
- Coverage   45.50%   41.14%   -4.37%     
==========================================
  Files          79      107      +28     
  Lines        7782    15529    +7747     
==========================================
+ Hits         3541     6389    +2848     
- Misses       4099     8670    +4571     
- Partials      142      470     +328     
```

</details>
saschagrunert: /ok-to-test

Thank you @mhils :pray: !

Patch:
diff --git a/cmd/spoc/main.go b/cmd/spoc/main.go
index 7beeb315d..841885762 100644
--- a/cmd/spoc/main.go
+++ b/cmd/spoc/main.go
@@ -26,6 +26,7 @@ import (
 
 	"sigs.k8s.io/security-profiles-operator/cmd"
 	spocli "sigs.k8s.io/security-profiles-operator/internal/pkg/cli"
+	"sigs.k8s.io/security-profiles-operator/internal/pkg/cli/converter"
 	"sigs.k8s.io/security-profiles-operator/internal/pkg/cli/merger"
 	"sigs.k8s.io/security-profiles-operator/internal/pkg/cli/puller"
 	"sigs.k8s.io/security-profiles-operator/internal/pkg/cli/pusher"
@@ -99,6 +100,27 @@ func main() {
 				},
 			},
 		},
+		&cli.Command{
+			Name:      "convert",
+			Aliases:   []string{"c"},
+			Usage:     "convert a security profile to its raw format",
+			Action:    convert,
+			ArgsUsage: "PROFILE",
+			Flags: []cli.Flag{
+				&cli.StringFlag{
+					Name:        converter.FlagOutputFile,
+					Aliases:     []string{"o"},
+					Usage:       "the output file path for the raw profile",
+					DefaultText: converter.DefaultOutputFile,
+					TakesFile:   true,
+				},
+				&cli.StringFlag{
+					Name:    converter.FlagProgramName,
+					Aliases: []string{"p"},
+					Usage:   "AppArmor only: the path to the program that is confined.",
+				},
+			},
+		},
 		&cli.Command{
 			Name:      "run",
 			Aliases:   []string{"x"},
@@ -227,6 +249,20 @@ func merge(ctx *cli.Context) error {
 	return nil
 }
 
+// convert runs the `spoc convert` subcommand.
+func convert(ctx *cli.Context) error {
+	options, err := converter.FromContext(ctx)
+	if err != nil {
+		return fmt.Errorf("build options: %w", err)
+	}
+
+	if err := converter.New(options).Run(); err != nil {
+		return fmt.Errorf("launch converter: %w", err)
+	}
+
+	return nil
+}
+
 // run runs the `spoc run` subcommand.
 func run(ctx *cli.Context) error {
 	options, err := runner.FromContext(ctx)
diff --git a/internal/pkg/artifact/artifact.go b/internal/pkg/artifact/artifact.go
index 4d664a7b6..ffcb0f312 100644
--- a/internal/pkg/artifact/artifact.go
+++ b/internal/pkg/artifact/artifact.go
@@ -18,6 +18,7 @@ package artifact
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"path/filepath"
 	"strings"
@@ -337,41 +338,33 @@ func (a *Artifact) Pull(
 		return nil, fmt.Errorf("read profile: %w", err)
 	}
 
-	a.logger.Info("Trying to unmarshal seccomp profile")
-	seccompProfile := &seccompprofileapi.SeccompProfile{}
-	err = a.YamlUnmarshal(content, seccompProfile)
-	if err == nil {
+	profile, err := a.ReadProfile(content)
+	if err != nil {
+		return nil, errors.Join(ErrDecodeYAML, err)
+	}
+
+	switch obj := profile.(type) {
+	case *seccompprofileapi.SeccompProfile:
 		return &PullResult{
 			typ:            PullResultTypeSeccompProfile,
-			seccompProfile: seccompProfile,
+			seccompProfile: obj,
 			content:        content,
 		}, nil
-	}
-
-	a.logger.Info("Trying to unmarshal selinux profile")
-	selinuxProfile := &selinuxprofileapi.SelinuxProfile{}
-	err = a.YamlUnmarshal(content, selinuxProfile)
-	if err == nil {
+	case *selinuxprofileapi.SelinuxProfile:
 		return &PullResult{
 			typ:            PullResultTypeSelinuxProfile,
-			selinuxProfile: selinuxProfile,
+			selinuxProfile: obj,
 			content:        content,
 		}, nil
-	}
-
-	a.logger.Info("Trying to unmarshal apparmor profile")
-	apparmorProfile := &apparmorprofileapi.AppArmorProfile{}
-	err = a.YamlUnmarshal(content, apparmorProfile)
-	if err == nil {
+	case *apparmorprofileapi.AppArmorProfile:
 		return &PullResult{
 			typ:             PullResultTypeApparmorProfile,
-			apparmorProfile: apparmorProfile,
+			apparmorProfile: obj,
 			content:         content,
 		}, nil
+	default:
+		return nil, fmt.Errorf("cannot process %T to PullResult", obj)
 	}
-
-	a.logger.Info("No profile found")
-	return nil, fmt.Errorf("%w: last err: %w", ErrDecodeYAML, err)
 }
 
 // profileName returns the name for the profile based on the platform.
diff --git a/internal/pkg/artifact/artifactfakes/fake_impl.go b/internal/pkg/artifact/artifactfakes/fake_impl.go
index 07be2fb39..9fcac2a4d 100644
--- a/internal/pkg/artifact/artifactfakes/fake_impl.go
+++ b/internal/pkg/artifact/artifactfakes/fake_impl.go
@@ -29,6 +29,7 @@ import (
 	"oras.land/oras-go/v2/content"
 	"oras.land/oras-go/v2/content/file"
 	"oras.land/oras-go/v2/registry/remote"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
 type FakeImpl struct {
@@ -171,6 +172,19 @@ type FakeImpl struct {
 		result1 []byte
 		result2 error
 	}
+	ReadProfileStub        func([]byte) (client.Object, error)
+	readProfileMutex       sync.RWMutex
+	readProfileArgsForCall []struct {
+		arg1 []byte
+	}
+	readProfileReturns struct {
+		result1 client.Object
+		result2 error
+	}
+	readProfileReturnsOnCall map[int]struct {
+		result1 client.Object
+		result2 error
+	}
 	RemoveAllStub        func(string) error
 	removeAllMutex       sync.RWMutex
 	removeAllArgsForCall []struct {
@@ -240,18 +254,6 @@ type FakeImpl struct {
 	verifyCmdReturnsOnCall map[int]struct {
 		result1 error
 	}
-	YamlUnmarshalStub        func([]byte, interface{}) error
-	yamlUnmarshalMutex       sync.RWMutex
-	yamlUnmarshalArgsForCall []struct {
-		arg1 []byte
-		arg2 interface{}
-	}
-	yamlUnmarshalReturns struct {
-		result1 error
-	}
-	yamlUnmarshalReturnsOnCall map[int]struct {
-		result1 error
-	}
 	invocations      map[string][][]interface{}
 	invocationsMutex sync.RWMutex
 }
@@ -904,6 +906,75 @@ func (fake *FakeImpl) ReadFileReturnsOnCall(i int, result1 []byte, result2 error
 	}{result1, result2}
 }
 
+func (fake *FakeImpl) ReadProfile(arg1 []byte) (client.Object, error) {
+	var arg1Copy []byte
+	if arg1 != nil {
+		arg1Copy = make([]byte, len(arg1))
+		copy(arg1Copy, arg1)
+	}
+	fake.readProfileMutex.Lock()
+	ret, specificReturn := fake.readProfileReturnsOnCall[len(fake.readProfileArgsForCall)]
+	fake.readProfileArgsForCall = append(fake.readProfileArgsForCall, struct {
+		arg1 []byte
+	}{arg1Copy})
+	stub := fake.ReadProfileStub
+	fakeReturns := fake.readProfileReturns
+	fake.recordInvocation("ReadProfile", []interface{}{arg1Copy})
+	fake.readProfileMutex.Unlock()
+	if stub != nil {
+		return stub(arg1)
+	}
+	if specificReturn {
+		return ret.result1, ret.result2
+	}
+	return fakeReturns.result1, fakeReturns.result2
+}
+
+func (fake *FakeImpl) ReadProfileCallCount() int {
+	fake.readProfileMutex.RLock()
+	defer fake.readProfileMutex.RUnlock()
+	return len(fake.readProfileArgsForCall)
+}
+
+func (fake *FakeImpl) ReadProfileCalls(stub func([]byte) (client.Object, error)) {
+	fake.readProfileMutex.Lock()
+	defer fake.readProfileMutex.Unlock()
+	fake.ReadProfileStub = stub
+}
+
+func (fake *FakeImpl) ReadProfileArgsForCall(i int) []byte {
+	fake.readProfileMutex.RLock()
+	defer fake.readProfileMutex.RUnlock()
+	argsForCall := fake.readProfileArgsForCall[i]
+	return argsForCall.arg1
+}
+
+func (fake *FakeImpl) ReadProfileReturns(result1 client.Object, result2 error) {
+	fake.readProfileMutex.Lock()
+	defer fake.readProfileMutex.Unlock()
+	fake.ReadProfileStub = nil
+	fake.readProfileReturns = struct {
+		result1 client.Object
+		result2 error
+	}{result1, result2}
+}
+
+func (fake *FakeImpl) ReadProfileReturnsOnCall(i int, result1 client.Object, result2 error) {
+	fake.readProfileMutex.Lock()
+	defer fake.readProfileMutex.Unlock()
+	fake.ReadProfileStub = nil
+	if fake.readProfileReturnsOnCall == nil {
+		fake.readProfileReturnsOnCall = make(map[int]struct {
+			result1 client.Object
+			result2 error
+		})
+	}
+	fake.readProfileReturnsOnCall[i] = struct {
+		result1 client.Object
+		result2 error
+	}{result1, result2}
+}
+
 func (fake *FakeImpl) RemoveAll(arg1 string) error {
 	fake.removeAllMutex.Lock()
 	ret, specificReturn := fake.removeAllReturnsOnCall[len(fake.removeAllArgsForCall)]
@@ -1229,73 +1300,6 @@ func (fake *FakeImpl) VerifyCmdReturnsOnCall(i int, result1 error) {
 	}{result1}
 }
 
-func (fake *FakeImpl) YamlUnmarshal(arg1 []byte, arg2 interface{}) error {
-	var arg1Copy []byte
-	if arg1 != nil {
-		arg1Copy = make([]byte, len(arg1))
-		copy(arg1Copy, arg1)
-	}
-	fake.yamlUnmarshalMutex.Lock()
-	ret, specificReturn := fake.yamlUnmarshalReturnsOnCall[len(fake.yamlUnmarshalArgsForCall)]
-	fake.yamlUnmarshalArgsForCall = append(fake.yamlUnmarshalArgsForCall, struct {
-		arg1 []byte
-		arg2 interface{}
-	}{arg1Copy, arg2})
-	stub := fake.YamlUnmarshalStub
-	fakeReturns := fake.yamlUnmarshalReturns
-	fake.recordInvocation("YamlUnmarshal", []interface{}{arg1Copy, arg2})
-	fake.yamlUnmarshalMutex.Unlock()
-	if stub != nil {
-		return stub(arg1, arg2)
-	}
-	if specificReturn {
-		return ret.result1
-	}
-	return fakeReturns.result1
-}
-
-func (fake *FakeImpl) YamlUnmarshalCallCount() int {
-	fake.yamlUnmarshalMutex.RLock()
-	defer fake.yamlUnmarshalMutex.RUnlock()
-	return len(fake.yamlUnmarshalArgsForCall)
-}
-
-func (fake *FakeImpl) YamlUnmarshalCalls(stub func([]byte, interface{}) error) {
-	fake.yamlUnmarshalMutex.Lock()
-	defer fake.yamlUnmarshalMutex.Unlock()
-	fake.YamlUnmarshalStub = stub
-}
-
-func (fake *FakeImpl) YamlUnmarshalArgsForCall(i int) ([]byte, interface{}) {
-	fake.yamlUnmarshalMutex.RLock()
-	defer fake.yamlUnmarshalMutex.RUnlock()
-	argsForCall := fake.yamlUnmarshalArgsForCall[i]
-	return argsForCall.arg1, argsForCall.arg2
-}
-
-func (fake *FakeImpl) YamlUnmarshalReturns(result1 error) {
-	fake.yamlUnmarshalMutex.Lock()
-	defer fake.yamlUnmarshalMutex.Unlock()
-	fake.YamlUnmarshalStub = nil
-	fake.yamlUnmarshalReturns = struct {
-		result1 error
-	}{result1}
-}
-
-func (fake *FakeImpl) YamlUnmarshalReturnsOnCall(i int, result1 error) {
-	fake.yamlUnmarshalMutex.Lock()
-	defer fake.yamlUnmarshalMutex.Unlock()
-	fake.YamlUnmarshalStub = nil
-	if fake.yamlUnmarshalReturnsOnCall == nil {
-		fake.yamlUnmarshalReturnsOnCall = make(map[int]struct {
-			result1 error
-		})
-	}
-	fake.yamlUnmarshalReturnsOnCall[i] = struct {
-		result1 error
-	}{result1}
-}
-
 func (fake *FakeImpl) Invocations() map[string][][]interface{} {
 	fake.invocationsMutex.RLock()
 	defer fake.invocationsMutex.RUnlock()
@@ -1319,6 +1323,8 @@ func (fake *FakeImpl) Invocations() map[string][][]interface{} {
 	defer fake.parseReferenceMutex.RUnlock()
 	fake.readFileMutex.RLock()
 	defer fake.readFileMutex.RUnlock()
+	fake.readProfileMutex.RLock()
+	defer fake.readProfileMutex.RUnlock()
 	fake.removeAllMutex.RLock()
 	defer fake.removeAllMutex.RUnlock()
 	fake.signCmdMutex.RLock()
@@ -1329,8 +1335,6 @@ func (fake *FakeImpl) Invocations() map[string][][]interface{} {
 	defer fake.storeTagMutex.RUnlock()
 	fake.verifyCmdMutex.RLock()
 	defer fake.verifyCmdMutex.RUnlock()
-	fake.yamlUnmarshalMutex.RLock()
-	defer fake.yamlUnmarshalMutex.RUnlock()
 	copiedInvocations := map[string][][]interface{}{}
 	for key, value := range fake.invocations {
 		copiedInvocations[key] = value
diff --git a/internal/pkg/artifact/impl.go b/internal/pkg/artifact/impl.go
index a1ea1e757..32a546163 100644
--- a/internal/pkg/artifact/impl.go
+++ b/internal/pkg/artifact/impl.go
@@ -30,7 +30,7 @@ import (
 	"oras.land/oras-go/v2/content"
 	"oras.land/oras-go/v2/content/file"
 	"oras.land/oras-go/v2/registry/remote"
-	"sigs.k8s.io/yaml"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 )
 
 type defaultImpl struct{}
@@ -47,7 +47,7 @@ type impl interface {
 	NewRepository(string) (*remote.Repository, error)
 	Copy(context.Context, oras.ReadOnlyTarget, string, oras.Target, string, oras.CopyOptions) (ocispec.Descriptor, error)
 	ReadFile(string) ([]byte, error)
-	YamlUnmarshal([]byte, interface{}) error
+	ReadProfile([]byte) (client.Object, error)
 	StoreAdd(context.Context, *file.Store, string, string, string) (ocispec.Descriptor, error)
 	StoreTag(context.Context, *file.Store, ocispec.Descriptor, string) error
 	PackManifest(
@@ -97,8 +97,8 @@ func (*defaultImpl) ReadFile(name string) ([]byte, error) {
 	return os.ReadFile(name)
 }
 
-func (*defaultImpl) YamlUnmarshal(y []byte, o interface{}) error {
-	return yaml.Unmarshal(y, o)
+func (*defaultImpl) ReadProfile(raw []byte) (client.Object, error) {
+	return ReadProfile(raw)
 }
 
 func (*defaultImpl) StoreAdd(
diff --git a/internal/pkg/artifact/profile_reader.go b/internal/pkg/artifact/profile_reader.go
new file mode 100644
index 000000000..36acaeaad
--- /dev/null
+++ b/internal/pkg/artifact/profile_reader.go
@@ -0,0 +1,68 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package artifact
+
+import (
+	"fmt"
+
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/yaml"
+
+	apparmorprofileapi "sigs.k8s.io/security-profiles-operator/api/apparmorprofile/v1alpha1"
+	seccompprofileapi "sigs.k8s.io/security-profiles-operator/api/seccompprofile/v1beta1"
+	selinuxprofileapi "sigs.k8s.io/security-profiles-operator/api/selinuxprofile/v1alpha2"
+)
+
+// Unmarshal a raw security profile YAML byte slice into a SeccompProfile, SelinuxProfile,
+// or AppArmorProfile struct. The caller can then use `switch obj := profile.(type) { ... `.
+func ReadProfile(content []byte) (client.Object, error) {
+	// yaml.Unmarshal happily takes YAML for a SELinux profile and unmarshals
+	// it into SeccompProfile. We need to check the YAML kind!
+	var genericCRD map[string]interface{}
+	err := yaml.Unmarshal(content, &genericCRD)
+	if err != nil {
+		fmt.Println(err)
+		return nil, fmt.Errorf("cannot parse yaml: %w", err)
+	}
+	kind, ok := genericCRD["kind"].(string)
+	if !ok {
+		return nil, fmt.Errorf("invalid yaml, kind missing: %w", err)
+	}
+
+	switch kind {
+	case "SeccompProfile":
+		var profile seccompprofileapi.SeccompProfile
+		if err := yaml.Unmarshal(content, &profile); err != nil {
+			return nil, fmt.Errorf("unmarshal to seccomp profile: %w", err)
+		}
+		return &profile, nil
+	case "SelinuxProfile":
+		var profile selinuxprofileapi.SelinuxProfile
+		if err := yaml.Unmarshal(content, &profile); err != nil {
+			return nil, fmt.Errorf("unmarshal to selinux profile: %w", err)
+		}
+		return &profile, nil
+	case "AppArmorProfile":
+		var profile apparmorprofileapi.AppArmorProfile
+		if err := yaml.Unmarshal(content, &profile); err != nil {
+			return nil, fmt.Errorf("unmarshal to apparmor profile: %w", err)
+		}
+		return &profile, nil
+	default:
+		return nil, fmt.Errorf("unexpected YAML kind: %s", kind)
+	}
+}
diff --git a/internal/pkg/cli/converter/consts.go b/internal/pkg/cli/converter/consts.go
new file mode 100644
index 000000000..e9e0d58bf
--- /dev/null
+++ b/internal/pkg/cli/converter/consts.go
@@ -0,0 +1,28 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package converter
+
+import "sigs.k8s.io/security-profiles-operator/internal/pkg/cli"
+
+// DefaultOutputFile defines the default output location for the merger.
+var DefaultOutputFile = "/dev/stdout"
+
+const (
+	// FlagOutputFile is the flag for defining the output file location.
+	FlagOutputFile  string = cli.FlagOutputFile
+	FlagProgramName string = "program-name"
+)
diff --git a/internal/pkg/cli/converter/converter.go b/internal/pkg/cli/converter/converter.go
new file mode 100644
index 000000000..159a15b71
--- /dev/null
+++ b/internal/pkg/cli/converter/converter.go
@@ -0,0 +1,98 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package converter
+
+import (
+	"encoding/json"
+	"fmt"
+	"log"
+
+	apparmorprofileapi "sigs.k8s.io/security-profiles-operator/api/apparmorprofile/v1alpha1"
+	seccompprofileapi "sigs.k8s.io/security-profiles-operator/api/seccompprofile/v1beta1"
+	"sigs.k8s.io/security-profiles-operator/internal/pkg/artifact"
+	"sigs.k8s.io/security-profiles-operator/internal/pkg/daemon/apparmorprofile/crd2armor"
+)
+
+// Converter is the main structure of this package.
+type Converter struct {
+	impl
+	options *Options
+}
+
+// New returns a new Converter instance.
+func New(options *Options) *Converter {
+	return &Converter{
+		impl:    &defaultImpl{},
+		options: options,
+	}
+}
+
+// Run the Converter.
+func (p *Converter) Run() error {
+	log.Printf("Converting %s to raw profile", p.options.inputFile)
+
+	content, err := p.ReadFile(p.options.inputFile)
+	if err != nil {
+		return fmt.Errorf("read profile file %s: %w", p.options.inputFile, err)
+	}
+
+	profile, err := artifact.ReadProfile(content)
+	if err != nil {
+		return fmt.Errorf("decoding %s: %w", p.options.inputFile, err)
+	}
+
+	var out []byte
+
+	switch obj := profile.(type) {
+	case *apparmorprofileapi.AppArmorProfile:
+		if obj.Spec.Policy != "" {
+			out = []byte(obj.Spec.Policy)
+		} else {
+			programName := p.options.programName
+			if programName == "" {
+				//nolint:lll  // long url is long
+				log.Printf("Creating an unattached AppArmor profile '%s'. "+
+					"Unattached profiles are not automatically attached to applications, see "+
+					"https://web.archive.org/web/20231211031731/https://documentation.suse.com/sles/15-SP3/html/SLES-all/cha-apparmor-profiles.html#sec-apparmor-profiles-types. "+
+					"Pass --%s to create a standard profile.",
+					obj.Name, FlagProgramName)
+				programName = obj.Name
+			}
+
+			outStr, err := crd2armor.GenerateProfile(programName, &obj.Spec.Abstract)
+			if err != nil {
+				return fmt.Errorf("build raw apparmor profile: %w", err)
+			}
+			out = []byte(outStr)
+		}
+	case *seccompprofileapi.SeccompProfile:
+		out, err = json.MarshalIndent(obj.Spec, "", "  ")
+		if err != nil {
+			return fmt.Errorf("marshal JSON profile: %w", err)
+		}
+	default:
+		return fmt.Errorf("cannot convert %T to raw profile", obj)
+	}
+
+	const filePermissions = 0o600
+	if err := p.WriteFile(p.options.outputFile, out, filePermissions); err != nil {
+		return fmt.Errorf("failed to write output file: %w", err)
+	}
+
+	log.Printf("Successfully wrote raw profile to %s.", p.options.outputFile)
+	return nil
+}
diff --git a/internal/pkg/cli/converter/converterfakes/fake_impl.go b/internal/pkg/cli/converter/converterfakes/fake_impl.go
new file mode 100644
index 000000000..f396eb863
--- /dev/null
+++ b/internal/pkg/cli/converter/converterfakes/fake_impl.go
@@ -0,0 +1,212 @@
+/*
+Copyright The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by counterfeiter. DO NOT EDIT.
+package converterfakes
+
+import (
+	"io/fs"
+	"sync"
+)
+
+type FakeImpl struct {
+	ReadFileStub        func(string) ([]byte, error)
+	readFileMutex       sync.RWMutex
+	readFileArgsForCall []struct {
+		arg1 string
+	}
+	readFileReturns struct {
+		result1 []byte
+		result2 error
+	}
+	readFileReturnsOnCall map[int]struct {
+		result1 []byte
+		result2 error
+	}
+	WriteFileStub        func(string, []byte, fs.FileMode) error
+	writeFileMutex       sync.RWMutex
+	writeFileArgsForCall []struct {
+		arg1 string
+		arg2 []byte
+		arg3 fs.FileMode
+	}
+	writeFileReturns struct {
+		result1 error
+	}
+	writeFileReturnsOnCall map[int]struct {
+		result1 error
+	}
+	invocations      map[string][][]interface{}
+	invocationsMutex sync.RWMutex
+}
+
+func (fake *FakeImpl) ReadFile(arg1 string) ([]byte, error) {
+	fake.readFileMutex.Lock()
+	ret, specificReturn := fake.readFileReturnsOnCall[len(fake.readFileArgsForCall)]
+	fake.readFileArgsForCall = append(fake.readFileArgsForCall, struct {
+		arg1 string
+	}{arg1})
+	stub := fake.ReadFileStub
+	fakeReturns := fake.readFileReturns
+	fake.recordInvocation("ReadFile", []interface{}{arg1})
+	fake.readFileMutex.Unlock()
+	if stub != nil {
+		return stub(arg1)
+	}
+	if specificReturn {
+		return ret.result1, ret.result2
+	}
+	return fakeReturns.result1, fakeReturns.result2
+}
+
+func (fake *FakeImpl) ReadFileCallCount() int {
+	fake.readFileMutex.RLock()
+	defer fake.readFileMutex.RUnlock()
+	return len(fake.readFileArgsForCall)
+}
+
+func (fake *FakeImpl) ReadFileCalls(stub func(string) ([]byte, error)) {
+	fake.readFileMutex.Lock()
+	defer fake.readFileMutex.Unlock()
+	fake.ReadFileStub = stub
+}
+
+func (fake *FakeImpl) ReadFileArgsForCall(i int) string {
+	fake.readFileMutex.RLock()
+	defer fake.readFileMutex.RUnlock()
+	argsForCall := fake.readFileArgsForCall[i]
+	return argsForCall.arg1
+}
+
+func (fake *FakeImpl) ReadFileReturns(result1 []byte, result2 error) {
+	fake.readFileMutex.Lock()
+	defer fake.readFileMutex.Unlock()
+	fake.ReadFileStub = nil
+	fake.readFileReturns = struct {
+		result1 []byte
+		result2 error
+	}{result1, result2}
+}
+
+func (fake *FakeImpl) ReadFileReturnsOnCall(i int, result1 []byte, result2 error) {
+	fake.readFileMutex.Lock()
+	defer fake.readFileMutex.Unlock()
+	fake.ReadFileStub = nil
+	if fake.readFileReturnsOnCall == nil {
+		fake.readFileReturnsOnCall = make(map[int]struct {
+			result1 []byte
+			result2 error
+		})
+	}
+	fake.readFileReturnsOnCall[i] = struct {
+		result1 []byte
+		result2 error
+	}{result1, result2}
+}
+
+func (fake *FakeImpl) WriteFile(arg1 string, arg2 []byte, arg3 fs.FileMode) error {
+	var arg2Copy []byte
+	if arg2 != nil {
+		arg2Copy = make([]byte, len(arg2))
+		copy(arg2Copy, arg2)
+	}
+	fake.writeFileMutex.Lock()
+	ret, specificReturn := fake.writeFileReturnsOnCall[len(fake.writeFileArgsForCall)]
+	fake.writeFileArgsForCall = append(fake.writeFileArgsForCall, struct {
+		arg1 string
+		arg2 []byte
+		arg3 fs.FileMode
+	}{arg1, arg2Copy, arg3})
+	stub := fake.WriteFileStub
+	fakeReturns := fake.writeFileReturns
+	fake.recordInvocation("WriteFile", []interface{}{arg1, arg2Copy, arg3})
+	fake.writeFileMutex.Unlock()
+	if stub != nil {
+		return stub(arg1, arg2, arg3)
+	}
+	if specificReturn {
+		return ret.result1
+	}
+	return fakeReturns.result1
+}
+
+func (fake *FakeImpl) WriteFileCallCount() int {
+	fake.writeFileMutex.RLock()
+	defer fake.writeFileMutex.RUnlock()
+	return len(fake.writeFileArgsForCall)
+}
+
+func (fake *FakeImpl) WriteFileCalls(stub func(string, []byte, fs.FileMode) error) {
+	fake.writeFileMutex.Lock()
+	defer fake.writeFileMutex.Unlock()
+	fake.WriteFileStub = stub
+}
+
+func (fake *FakeImpl) WriteFileArgsForCall(i int) (string, []byte, fs.FileMode) {
+	fake.writeFileMutex.RLock()
+	defer fake.writeFileMutex.RUnlock()
+	argsForCall := fake.writeFileArgsForCall[i]
+	return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3
+}
+
+func (fake *FakeImpl) WriteFileReturns(result1 error) {
+	fake.writeFileMutex.Lock()
+	defer fake.writeFileMutex.Unlock()
+	fake.WriteFileStub = nil
+	fake.writeFileReturns = struct {
+		result1 error
+	}{result1}
+}
+
+func (fake *FakeImpl) WriteFileReturnsOnCall(i int, result1 error) {
+	fake.writeFileMutex.Lock()
+	defer fake.writeFileMutex.Unlock()
+	fake.WriteFileStub = nil
+	if fake.writeFileReturnsOnCall == nil {
+		fake.writeFileReturnsOnCall = make(map[int]struct {
+			result1 error
+		})
+	}
+	fake.writeFileReturnsOnCall[i] = struct {
+		result1 error
+	}{result1}
+}
+
+func (fake *FakeImpl) Invocations() map[string][][]interface{} {
+	fake.invocationsMutex.RLock()
+	defer fake.invocationsMutex.RUnlock()
+	fake.readFileMutex.RLock()
+	defer fake.readFileMutex.RUnlock()
+	fake.writeFileMutex.RLock()
+	defer fake.writeFileMutex.RUnlock()
+	copiedInvocations := map[string][][]interface{}{}
+	for key, value := range fake.invocations {
+		copiedInvocations[key] = value
+	}
+	return copiedInvocations
+}
+
+func (fake *FakeImpl) recordInvocation(key string, args []interface{}) {
+	fake.invocationsMutex.Lock()
+	defer fake.invocationsMutex.Unlock()
+	if fake.invocations == nil {
+		fake.invocations = map[string][][]interface{}{}
+	}
+	if fake.invocations[key] == nil {
+		fake.invocations[key] = [][]interface{}{}
+	}
+	fake.invocations[key] = append(fake.invocations[key], args)
+}
diff --git a/internal/pkg/cli/converter/impl.go b/internal/pkg/cli/converter/impl.go
new file mode 100644
index 000000000..94fa042c4
--- /dev/null
+++ b/internal/pkg/cli/converter/impl.go
@@ -0,0 +1,38 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package converter
+
+import (
+	"os"
+)
+
+type defaultImpl struct{}
+
+//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate -header ../../../../hack/boilerplate/boilerplate.generatego.txt
+//counterfeiter:generate . impl
+type impl interface {
+	ReadFile(string) ([]byte, error)
+	WriteFile(name string, data []byte, perm os.FileMode) error
+}
+
+func (*defaultImpl) ReadFile(name string) ([]byte, error) {
+	return os.ReadFile(name)
+}
+
+func (*defaultImpl) WriteFile(name string, data []byte, perm os.FileMode) error {
+	return os.WriteFile(name, data, perm)
+}
diff --git a/internal/pkg/cli/converter/options.go b/internal/pkg/cli/converter/options.go
new file mode 100644
index 000000000..a21de4cd4
--- /dev/null
+++ b/internal/pkg/cli/converter/options.go
@@ -0,0 +1,61 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package converter
+
+import (
+	"errors"
+
+	ucli "github.com/urfave/cli/v2"
+)
+
+// Options define all possible options for the puller.
+type Options struct {
+	inputFile   string
+	outputFile  string
+	programName string
+}
+
+// Default returns a default options instance.
+func Default() *Options {
+	return &Options{
+		outputFile: DefaultOutputFile,
+	}
+}
+
+// FromContext can be used to create Options from an CLI context.
+func FromContext(ctx *ucli.Context) (*Options, error) {
+	options := Default()
+
+	args := ctx.Args().Slice()
+	if len(args) == 0 {
+		return nil, errors.New("no profile provided")
+	}
+	options.inputFile = args[0]
+
+	if ctx.IsSet(FlagOutputFile) {
+		options.outputFile = ctx.String(FlagOutputFile)
+	}
+	if options.outputFile == "" {
+		return nil, errors.New("no filename provided")
+	}
+
+	if ctx.IsSet(FlagProgramName) {
+		options.programName = ctx.String(FlagProgramName)
+	}
+
+	return options, nil
+}
diff --git a/internal/pkg/cli/merger/merger.go b/internal/pkg/cli/merger/merger.go
index b4f4c4c58..45bf55c83 100644
--- a/internal/pkg/cli/merger/merger.go
+++ b/internal/pkg/cli/merger/merger.go
@@ -23,11 +23,8 @@ import (
 
 	"k8s.io/cli-runtime/pkg/printers"
 	"sigs.k8s.io/controller-runtime/pkg/client"
-	"sigs.k8s.io/yaml"
 
-	apparmorprofileapi "sigs.k8s.io/security-profiles-operator/api/apparmorprofile/v1alpha1"
-	seccompprofileapi "sigs.k8s.io/security-profiles-operator/api/seccompprofile/v1beta1"
-	selinuxprofileapi "sigs.k8s.io/security-profiles-operator/api/selinuxprofile/v1alpha2"
+	"sigs.k8s.io/security-profiles-operator/internal/pkg/artifact"
 	"sigs.k8s.io/security-profiles-operator/internal/pkg/manager/recordingmerger"
 )
 
@@ -57,40 +54,9 @@ func (p *Merger) Run() error {
 			return fmt.Errorf("open profile: %w", err)
 		}
 
-		// yaml.Unmarshal happily takes YAML for a SELinux profile and unmarshals
-		// it into SeccompProfile. We need to check the YAML kind!
-		var genericCRD map[string]interface{}
-		err = yaml.Unmarshal(content, &genericCRD)
+		contents[i], err = artifact.ReadProfile(content)
 		if err != nil {
-			fmt.Println(err)
-			return fmt.Errorf("cannot parse yaml: %w", err)
-		}
-		kind, ok := genericCRD["kind"].(string)
-		if !ok {
-			return fmt.Errorf("invalid yaml, kind missing: %w", err)
-		}
-
-		switch kind {
-		case "SeccompProfile":
-			var profile seccompprofileapi.SeccompProfile
-			if err := yaml.Unmarshal(content, &profile); err != nil {
-				return fmt.Errorf("unmarshal to seccomp profile: %w", err)
-			}
-			contents[i] = &profile
-		case "SelinuxProfile":
-			var profile selinuxprofileapi.SelinuxProfile
-			if err := yaml.Unmarshal(content, &profile); err != nil {
-				return fmt.Errorf("unmarshal to selinux profile: %w", err)
-			}
-			contents[i] = &profile
-		case "AppArmorProfile":
-			var profile apparmorprofileapi.AppArmorProfile
-			if err := yaml.Unmarshal(content, &profile); err != nil {
-				return fmt.Errorf("unmarshal to apparmor profile: %w", err)
-			}
-			contents[i] = &profile
-		default:
-			return fmt.Errorf("unexpected YAML kind: %s", kind)
+			return fmt.Errorf("failed to read %s: %w", filepath, err)
 		}
 	}
 
diff --git a/internal/pkg/cli/recorder/recorder.go b/internal/pkg/cli/recorder/recorder.go
index f24baaf8a..a1ad822e4 100644
--- a/internal/pkg/cli/recorder/recorder.go
+++ b/internal/pkg/cli/recorder/recorder.go
@@ -181,18 +181,20 @@ func (r *Recorder) generateAppArmorProfile() apparmorprofileapi.AppArmorAbstract
 	abstract := apparmorprofileapi.AppArmorAbstract{}
 	enabled := true
 
-	abstract.Executable = &apparmorprofileapi.AppArmorExecutablesRules{}
-	if len(processed.FileProcessed.AllowedExecutables) != 0 {
-		sort.Strings(processed.FileProcessed.AllowedExecutables)
-		ExecutableAllowedExecCopy := make([]string, len(processed.FileProcessed.AllowedExecutables))
-		copy(ExecutableAllowedExecCopy, processed.FileProcessed.AllowedExecutables)
-		abstract.Executable.AllowedExecutables = &ExecutableAllowedExecCopy
-	}
-	if len(processed.FileProcessed.AllowedLibraries) != 0 {
-		sort.Strings(processed.FileProcessed.AllowedLibraries)
-		ExecutableAllowedLibCopy := make([]string, len(processed.FileProcessed.AllowedLibraries))
-		copy(ExecutableAllowedLibCopy, processed.FileProcessed.AllowedLibraries)
-		abstract.Executable.AllowedLibraries = &ExecutableAllowedLibCopy
+	if len(processed.FileProcessed.AllowedExecutables) != 0 || len(processed.FileProcessed.AllowedLibraries) != 0 {
+		abstract.Executable = &apparmorprofileapi.AppArmorExecutablesRules{}
+		if len(processed.FileProcessed.AllowedExecutables) != 0 {
+			sort.Strings(processed.FileProcessed.AllowedExecutables)
+			ExecutableAllowedExecCopy := make([]string, len(processed.FileProcessed.AllowedExecutables))
+			copy(ExecutableAllowedExecCopy, processed.FileProcessed.AllowedExecutables)
+			abstract.Executable.AllowedExecutables = &ExecutableAllowedExecCopy
+		}
+		if len(processed.FileProcessed.AllowedLibraries) != 0 {
+			sort.Strings(processed.FileProcessed.AllowedLibraries)
+			ExecutableAllowedLibCopy := make([]string, len(processed.FileProcessed.AllowedLibraries))
+			copy(ExecutableAllowedLibCopy, processed.FileProcessed.AllowedLibraries)
+			abstract.Executable.AllowedLibraries = &ExecutableAllowedLibCopy
+		}
 	}
 
 	if (len(processed.FileProcessed.ReadOnlyPaths) != 0) ||
@@ -374,8 +376,13 @@ func (r *Recorder) buildAppArmorProfileRaw(spec *apparmorprofileapi.AppArmorProf
 		r.options.outputFile = strings.ReplaceAll(r.options.outputFile, ".yaml", ".apparmor")
 	}
 
+	programName, err := filepath.Abs(r.options.commandOptions.Command())
+	if err != nil {
+		return fmt.Errorf("get program name: %w", err)
+	}
+
 	abstract := spec.Abstract
-	raw, err := crd2armor.GenerateProfile(r.options.commandOptions.Command(), &abstract)
+	raw, err := crd2armor.GenerateProfile(programName, &abstract)
 	if err != nil {
 		return fmt.Errorf("build raw apparmor profile: %w", err)
 	}
diff --git a/internal/pkg/daemon/apparmorprofile/crd2armor/crd2armor.go b/internal/pkg/daemon/apparmorprofile/crd2armor/crd2armor.go
index f167e2499..f90d75f59 100644
--- a/internal/pkg/daemon/apparmorprofile/crd2armor/crd2armor.go
+++ b/internal/pkg/daemon/apparmorprofile/crd2armor/crd2armor.go
@@ -19,7 +19,6 @@ package crd2armor
 import (
 	"bytes"
 	"errors"
-	"path/filepath"
 	"text/template"
 
 	apparmorprofileapi "sigs.k8s.io/security-profiles-operator/api/apparmorprofile/v1alpha1"
@@ -100,11 +99,6 @@ type apparmorTemplateArgs struct {
 func GenerateProfile(name string, abstract *apparmorprofileapi.AppArmorAbstract) (string, error) {
 	var generated bytes.Buffer
 
-	name, err := filepath.Abs(name)
-	if err != nil {
-		return "", err
-	}
-
 	templateArgs := apparmorTemplateArgs{
 		Name:     name,
 		Abstract: abstract,
diff --git a/internal/pkg/daemon/bpfrecorder/apparmor_filters.go b/internal/pkg/daemon/bpfrecorder/apparmor_filters.go
index 277a150bf..195d84233 100644
--- a/internal/pkg/daemon/bpfrecorder/apparmor_filters.go
+++ b/internal/pkg/daemon/bpfrecorder/apparmor_filters.go
@@ -46,6 +46,7 @@ var knownReadPrefixes = []string{
 	"/etc/localtime",
 	"/sys/kernel/mm/transparent_hugepage/hpage_pmd_size",
 	"/usr/share/locale/",
+	"/usr/lib/locale/",
 }
 
 // List of known paths for commonly written to files.


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 006f92c6f..af588b5c6 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -35,7 +35,7 @@ jobs:
       - name: Install dependencies
         run: sudo hack/install-packages
       - run: make test-unit
-      - uses: codecov/codecov-action@7afa10ed9b269c561c2336fd862446844e0cbf71
+      - uses: codecov/codecov-action@84508663e988701840491b86de86b666e8a86bed
         with:
           files: build/coverage.out
           flags: unittests
@@ -209,6 +209,6 @@ jobs:
     runs-on: ubuntu-22.04
     steps:
       - uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v3.5.2
-      - uses: crate-ci/typos@v1.20.4
+      - uses: crate-ci/typos@v1.20.7
         with:
           config: .typos.toml
diff --git a/internal/pkg/artifact/artifact_test.go b/internal/pkg/artifact/artifact_test.go
index 2728b92f8..2a0121faa 100644
--- a/internal/pkg/artifact/artifact_test.go
+++ b/internal/pkg/artifact/artifact_test.go
@@ -28,6 +28,9 @@ import (
 	"github.com/stretchr/testify/require"
 	"oras.land/oras-go/v2/registry/remote"
 
+	apparmorprofileapi "sigs.k8s.io/security-profiles-operator/api/apparmorprofile/v1alpha1"
+	seccompprofileapi "sigs.k8s.io/security-profiles-operator/api/seccompprofile/v1beta1"
+	selinuxprofileapi "sigs.k8s.io/security-profiles-operator/api/selinuxprofile/v1alpha2"
 	"sigs.k8s.io/security-profiles-operator/internal/pkg/artifact/artifactfakes"
 )
 
@@ -233,6 +236,7 @@ func TestPull(t *testing.T) {
 				mock.NewRepositoryReturns(&remote.Repository{}, nil)
 				mock.ParseReferenceReturns(testRef, nil)
 				mock.ReadFileReturns([]byte{}, nil)
+				mock.ReadProfileReturns(&seccompprofileapi.SeccompProfile{}, nil)
 				mock.RemoveAllReturns(errTest)
 				mock.FileCloseReturns(errTest)
 			},
@@ -250,6 +254,7 @@ func TestPull(t *testing.T) {
 				mock.NewRepositoryReturns(&remote.Repository{}, nil)
 				mock.ParseReferenceReturns(testRef, nil)
 				mock.ReadFileReturns([]byte{}, nil)
+				mock.ReadProfileReturns(&seccompprofileapi.SeccompProfile{}, nil)
 			},
 			assert: func(res *PullResult, err error) {
 				require.NoError(t, err)
@@ -265,7 +270,7 @@ func TestPull(t *testing.T) {
 				mock.NewRepositoryReturns(&remote.Repository{}, nil)
 				mock.ParseReferenceReturns(testRef, nil)
 				mock.ReadFileReturns([]byte{}, nil)
-				mock.YamlUnmarshalReturnsOnCall(0, errTest)
+				mock.ReadProfileReturns(&selinuxprofileapi.SelinuxProfile{}, nil)
 			},
 			assert: func(res *PullResult, err error) {
 				require.NoError(t, err)
@@ -281,8 +286,7 @@ func TestPull(t *testing.T) {
 				mock.NewRepositoryReturns(&remote.Repository{}, nil)
 				mock.ParseReferenceReturns(testRef, nil)
 				mock.ReadFileReturns([]byte{}, nil)
-				mock.YamlUnmarshalReturnsOnCall(0, errTest)
-				mock.YamlUnmarshalReturnsOnCall(1, errTest)
+				mock.ReadProfileReturns(&apparmorprofileapi.AppArmorProfile{}, nil)
 			},
 			assert: func(res *PullResult, err error) {
 				require.NoError(t, err)
@@ -298,7 +302,7 @@ func TestPull(t *testing.T) {
 				mock.NewRepositoryReturns(&remote.Repository{}, nil)
 				mock.ParseReferenceReturns(testRef, nil)
 				mock.ReadFileReturns([]byte{}, nil)
-				mock.YamlUnmarshalReturns(errTest)
+				mock.ReadProfileReturns(nil, errTest)
 			},
 			assert: func(res *PullResult, err error) {
 				require.ErrorIs(t, err, ErrDecodeYAML)
diff --git a/internal/pkg/artifact/profile_reader_test.go b/internal/pkg/artifact/profile_reader_test.go
new file mode 100644
index 000000000..9f45b6097
--- /dev/null
+++ b/internal/pkg/artifact/profile_reader_test.go
@@ -0,0 +1,100 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package artifact
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	apparmorprofileapi "sigs.k8s.io/security-profiles-operator/api/apparmorprofile/v1alpha1"
+	seccompprofile "sigs.k8s.io/security-profiles-operator/api/seccompprofile/v1beta1"
+	selinuxprofileapi "sigs.k8s.io/security-profiles-operator/api/selinuxprofile/v1alpha2"
+)
+
+const SeccompProfileExample = `
+apiVersion: security-profiles-operator.x-k8s.io/v1beta1
+kind: SeccompProfile
+spec:
+  defaultAction: SCMP_ACT_ERRNO
+  syscalls:
+    - action: SCMP_ACT_ALLOW
+      names:
+        - foo
+`
+
+const SelinuxProfileExample = `
+apiVersion: security-profiles-operator.x-k8s.io/v1alpha2
+kind: SelinuxProfile
+spec:
+  inherit:
+    - name: container
+  allow:
+    var_log_t:
+      dir:
+        - open
+`
+
+const AppArmorProfileExample = `
+apiVersion: security-profiles-operator.x-k8s.io/v1alpha1
+kind: AppArmorProfile
+spec:
+  abstract:
+    filesystem:
+      readOnlyPaths:
+      - /dev/null
+`
+
+func TestReadProfile(t *testing.T) {
+	t.Parallel()
+	t.Run("AppArmor", func(t *testing.T) {
+		t.Parallel()
+		profile, err := ReadProfile([]byte(AppArmorProfileExample))
+		require.Nil(t, err)
+		require.IsType(t, &apparmorprofileapi.AppArmorProfile{}, profile)
+	})
+	t.Run("SELinux", func(t *testing.T) {
+		t.Parallel()
+		profile, err := ReadProfile([]byte(SelinuxProfileExample))
+		require.Nil(t, err)
+		require.IsType(t, &selinuxprofileapi.SelinuxProfile{}, profile)
+	})
+	t.Run("seccomp", func(t *testing.T) {
+		t.Parallel()
+		profile, err := ReadProfile([]byte(SeccompProfileExample))
+		require.Nil(t, err)
+		require.IsType(t, &seccompprofile.SeccompProfile{}, profile)
+	})
+
+	t.Run("invalid file", func(t *testing.T) {
+		t.Parallel()
+		_, err := ReadProfile([]byte("\x00"))
+		require.ErrorContains(t, err, "cannot parse yaml")
+	})
+
+	t.Run("invalid yaml", func(t *testing.T) {
+		t.Parallel()
+		_, err := ReadProfile([]byte("{}"))
+		require.ErrorContains(t, err, "kind missing")
+	})
+
+	t.Run("unknown kind", func(t *testing.T) {
+		t.Parallel()
+		_, err := ReadProfile([]byte("kind: unknown"))
+		require.ErrorContains(t, err, "unexpected YAML kind")
+	})
+}
diff --git a/internal/pkg/cli/converter/converter_test.go b/internal/pkg/cli/converter/converter_test.go
new file mode 100644
index 000000000..f76e6faa9
--- /dev/null
+++ b/internal/pkg/cli/converter/converter_test.go
@@ -0,0 +1,148 @@
+//go:build linux && !no_bpf
+// +build linux,!no_bpf
+
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package converter
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"sigs.k8s.io/security-profiles-operator/internal/pkg/cli/converter/converterfakes"
+)
+
+func TestRun(t *testing.T) {
+	t.Parallel()
+
+	defaultOptions := func() *Options {
+		options := Default()
+		options.inputFile = "input.yaml"
+		return options
+	}
+
+	for _, tc := range []struct {
+		name           string
+		input          string
+		outputContains string
+	}{
+		{
+			name: "AppArmor CRD",
+			input: `
+apiVersion: security-profiles-operator.x-k8s.io/v1alpha1
+kind: AppArmorProfile
+spec:
+  abstract:
+    filesystem:
+      readOnlyPaths:
+      - /dev/null
+`,
+			outputContains: `deny /dev/null wl`,
+		},
+		{
+			name: "AppArmor Policy",
+			input: `
+apiVersion: security-profiles-operator.x-k8s.io/v1alpha1
+kind: AppArmorProfile
+spec:
+  policy: |
+    #include <tunables/global>
+
+    profile test-profile flags=(attach_disconnected) {
+      #include <abstractions/base>
+      deny /** w,
+    }
+`,
+			outputContains: `deny /** w,`,
+		},
+		{
+			name: "seccomp",
+			input: `
+apiVersion: security-profiles-operator.x-k8s.io/v1beta1
+kind: SeccompProfile
+spec:
+  defaultAction: SCMP_ACT_ERRNO
+  syscalls:
+  - action: SCMP_ACT_ALLOW
+    names:
+    - foo
+`,
+			outputContains: `"defaultAction": "SCMP_ACT_ERRNO"`,
+		},
+	} {
+		input := tc.input
+		outputContains := tc.outputContains
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			mock := &converterfakes.FakeImpl{}
+			sut := New(defaultOptions())
+			sut.impl = mock
+			mock.ReadFileReturns([]byte(input), nil)
+
+			err := sut.Run()
+			require.Nil(t, err)
+			_, actual, _ := mock.WriteFileArgsForCall(0)
+			require.Contains(t, string(actual), outputContains)
+		})
+	}
+
+	for _, tc := range []struct {
+		name    string
+		prepare func(*converterfakes.FakeImpl) *Options
+		assert  func(*converterfakes.FakeImpl, error)
+	}{
+		{
+			name: "input file not found",
+			prepare: func(mock *converterfakes.FakeImpl) *Options {
+				mock.ReadFileReturnsOnCall(0, nil, errors.New("file not found"))
+				return defaultOptions()
+			},
+			assert: func(mock *converterfakes.FakeImpl, err error) {
+				require.ErrorContains(t, err, "file not found")
+			},
+		},
+		{
+			name: "input file is not yaml",
+			prepare: func(mock *converterfakes.FakeImpl) *Options {
+				mock.ReadFileReturnsOnCall(0, []byte("% this is not yaml"), nil)
+				return defaultOptions()
+			},
+			assert: func(mock *converterfakes.FakeImpl, err error) {
+				require.ErrorContains(t, err, "cannot parse yaml")
+			},
+		},
+	} {
+		prepare := tc.prepare
+		assert := tc.assert
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			mock := &converterfakes.FakeImpl{}
+			options := prepare(mock)
+
+			sut := New(options)
+			sut.impl = mock
+
+			err := sut.Run()
+			assert(mock, err)
+		})
+	}
+}
diff --git a/internal/pkg/cli/converter/options_test.go b/internal/pkg/cli/converter/options_test.go
new file mode 100644
index 000000000..276b09264
--- /dev/null
+++ b/internal/pkg/cli/converter/options_test.go
@@ -0,0 +1,67 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package converter
+
+import (
+	"flag"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"github.com/urfave/cli/v2"
+)
+
+func TestFromContext(t *testing.T) {
+	t.Parallel()
+	for _, tc := range []struct {
+		prepare func(*flag.FlagSet)
+		assert  func(error)
+	}{
+		{ // Success
+			prepare: func(set *flag.FlagSet) {
+				require.Nil(t, set.Parse([]string{"foo.yaml"}))
+			},
+			assert: func(err error) {
+				require.Nil(t, err)
+			},
+		},
+		{ // failure: no profiles provided
+			prepare: func(set *flag.FlagSet) {},
+			assert: func(err error) {
+				require.NotNil(t, err)
+			},
+		},
+		{ // failure: no filename provided
+			prepare: func(set *flag.FlagSet) {
+				set.String(FlagOutputFile, "", "")
+				require.Nil(t, set.Set(FlagOutputFile, ""))
+				require.Nil(t, set.Parse([]string{"foo.yaml"}))
+			},
+			assert: func(err error) {
+				require.NotNil(t, err)
+			},
+		},
+	} {
+		set := flag.NewFlagSet("", flag.ExitOnError)
+		tc.prepare(set)
+
+		app := cli.NewApp()
+		ctx := cli.NewContext(app, set, nil)
+
+		_, err := FromContext(ctx)
+		tc.assert(err)
+	}
+}
diff --git a/internal/pkg/cli/merger/merger_test.go b/internal/pkg/cli/merger/merger_test.go
index 7bb86a075..b8a357bca 100644
--- a/internal/pkg/cli/merger/merger_test.go
+++ b/internal/pkg/cli/merger/merger_test.go
@@ -21,7 +21,6 @@ package merger
 
 import (
 	"errors"
-	"fmt"
 	"testing"
 
 	"github.com/stretchr/testify/require"
@@ -152,7 +151,6 @@ func TestRun(t *testing.T) {
 			sut.impl = mock
 
 			err := sut.Run()
-			fmt.Println("err", err)
 			assert(mock, err)
 		})
 	}