Skip to content

Commit

Permalink
Merge pull request #10231 from deckhouse/no-cloud-public-keys
Browse files Browse the repository at this point in the history
Propogate public-keys to cloud-init NoCloud meta-data
  • Loading branch information
kubevirt-bot committed Sep 25, 2023
2 parents a22d2c1 + 6c22622 commit 9a94b8c
Show file tree
Hide file tree
Showing 11 changed files with 304 additions and 94 deletions.
7 changes: 7 additions & 0 deletions api/openapi-spec/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -18223,6 +18223,9 @@
}
}
},
"v1.NoCloudSSHPublicKeyAccessCredentialPropagation": {
"type": "object"
},
"v1.NodeMediatedDeviceTypesConfig": {
"description": "NodeMediatedDeviceTypesConfig holds information about MDEV types to be defined in a specific node that matches the NodeSelector field.",
"type": "object",
Expand Down Expand Up @@ -18874,6 +18877,10 @@
"description": "ConfigDrivePropagation means that the ssh public keys are injected into the VM using metadata using the configDrive cloud-init provider",
"$ref": "#/definitions/v1.ConfigDriveSSHPublicKeyAccessCredentialPropagation"
},
"noCloud": {
"description": "NoCloudPropagation means that the ssh public keys are injected into the VM using metadata using the noCloud cloud-init provider",
"$ref": "#/definitions/v1.NoCloudSSHPublicKeyAccessCredentialPropagation"
},
"qemuGuestAgent": {
"description": "QemuGuestAgentAccessCredentailPropagation means ssh public keys are dynamically injected into the vm at runtime via the qemu guest agent. This feature requires the qemu guest agent to be running within the guest.",
"$ref": "#/definitions/v1.QemuGuestAgentSSHPublicKeyAccessCredentialPropagation"
Expand Down
118 changes: 72 additions & 46 deletions pkg/cloud-init/cloud-init.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,10 @@ type PublicSSHKey struct {
}

type NoCloudMetadata struct {
InstanceType string `json:"instance-type,omitempty"`
InstanceID string `json:"instance-id"`
LocalHostname string `json:"local-hostname,omitempty"`
InstanceType string `json:"instance-type,omitempty"`
InstanceID string `json:"instance-id"`
LocalHostname string `json:"local-hostname,omitempty"`
PublicSSHKeys map[string]string `json:"public-keys,omitempty"`
}

type ConfigDriveMetadata struct {
Expand Down Expand Up @@ -135,13 +136,13 @@ func ReadCloudInitVolumeDataSource(vmi *v1.VirtualMachineInstance, secretSourceD

for _, volume := range vmi.Spec.Volumes {
if volume.CloudInitNoCloud != nil {
err := resolveNoCloudSecrets(vmi, secretSourceDir)
keys, err := resolveNoCloudSecrets(vmi, secretSourceDir)
if err != nil {
return nil, err
}

cloudInitData, err = readCloudInitNoCloudSource(volume.CloudInitNoCloud)
cloudInitData.NoCloudMetaData = readCloudInitNoCloudMetaData(hostname, cloudInitUUIDFromVMI(vmi), instancetype)
cloudInitData.NoCloudMetaData = readCloudInitNoCloudMetaData(hostname, cloudInitUUIDFromVMI(vmi), instancetype, keys)
cloudInitData.VolumeName = volume.Name
return cloudInitData, err
}
Expand All @@ -162,52 +163,20 @@ func ReadCloudInitVolumeDataSource(vmi *v1.VirtualMachineInstance, secretSourceD
return nil, nil
}

// resolveNoCloudSecrets is looking for CloudInitNoCloud volumes with UserDataSecretRef
// requests. It reads the `userdata` secret the corresponds to the given CloudInitNoCloud
// volume and sets the UserData field on that volume.
//
// Note: when using this function, make sure that your code can access the secret volumes.
func resolveNoCloudSecrets(vmi *v1.VirtualMachineInstance, secretSourceDir string) error {
volume := findCloudInitNoCloudSecretVolume(vmi.Spec.Volumes)
if volume == nil {
return nil
}

baseDir := filepath.Join(secretSourceDir, volume.Name)
var userDataError, networkDataError error
var userData, networkData string
if volume.CloudInitNoCloud.UserDataSecretRef != nil {
userData, userDataError = readFirstFoundFileFromDir(baseDir, []string{"userdata", "userData"})
}
if volume.CloudInitNoCloud.NetworkDataSecretRef != nil {
networkData, networkDataError = readFirstFoundFileFromDir(baseDir, []string{"networkdata", "networkData"})
}
if userDataError != nil && networkDataError != nil {
return fmt.Errorf("no cloud-init data-source found at volume: %s", volume.Name)
}

if userData != "" {
volume.CloudInitNoCloud.UserData = userData
}
if networkData != "" {
volume.CloudInitNoCloud.NetworkData = networkData
}
func isNoCloudAccessCredential(accessCred v1.AccessCredential) bool {
return accessCred.SSHPublicKey != nil && accessCred.SSHPublicKey.PropagationMethod.NoCloud != nil
}

return nil
func isConfigDriveAccessCredential(accessCred v1.AccessCredential) bool {
return accessCred.SSHPublicKey != nil && accessCred.SSHPublicKey.PropagationMethod.ConfigDrive != nil
}

// resolveConfigDriveSecrets is looking for CloudInitConfigDriveSource volume source with
// UserDataSecretRef and NetworkDataSecretRef and resolves the secret from the corresponding
// VolumeMount.
//
// Note: when using this function, make sure that your code can access the secret volumes.
func resolveConfigDriveSecrets(vmi *v1.VirtualMachineInstance, secretSourceDir string) (map[string]string, error) {
func resolveSSHPublicKeys(accessCredentials []v1.AccessCredential, secretSourceDir string, isAccessCredentialValidFunc func(v1.AccessCredential) bool) (map[string]string, error) {
keys := make(map[string]string)
count := 0
for _, accessCred := range vmi.Spec.AccessCredentials {
for _, accessCred := range accessCredentials {

// check to see if access credential is propagated by config drive or not
if accessCred.SSHPublicKey == nil || accessCred.SSHPublicKey.PropagationMethod.ConfigDrive == nil {
if !isAccessCredentialValidFunc(accessCred) {
continue
}

Expand Down Expand Up @@ -243,6 +212,62 @@ func resolveConfigDriveSecrets(vmi *v1.VirtualMachineInstance, secretSourceDir s
count++
}
}
return keys, nil
}

// resolveNoCloudSecrets is looking for CloudInitNoCloud volumes with UserDataSecretRef
// requests. It reads the `userdata` secret the corresponds to the given CloudInitNoCloud
// volume and sets the UserData field on that volume.
//
// Note: when using this function, make sure that your code can access the secret volumes.
func resolveNoCloudSecrets(vmi *v1.VirtualMachineInstance, secretSourceDir string) (map[string]string, error) {
keys, err := resolveSSHPublicKeys(vmi.Spec.AccessCredentials, secretSourceDir, isNoCloudAccessCredential)
if err != nil {
return keys, err
}

volume := findCloudInitNoCloudSecretVolume(vmi.Spec.Volumes)
if volume == nil {
return keys, nil
}

baseDir := filepath.Join(secretSourceDir, volume.Name)
var userDataError, networkDataError error
var userData, networkData string
if volume.CloudInitNoCloud.UserDataSecretRef != nil {
userData, userDataError = readFirstFoundFileFromDir(baseDir, []string{"userdata", "userData"})
}
if volume.CloudInitNoCloud.NetworkDataSecretRef != nil {
networkData, networkDataError = readFirstFoundFileFromDir(baseDir, []string{"networkdata", "networkData"})
}
if userDataError != nil && networkDataError != nil {
return keys, fmt.Errorf("no cloud-init data-source found at volume: %s", volume.Name)
}

if userData != "" {
volume.CloudInitNoCloud.UserData = userData
}
if networkData != "" {
volume.CloudInitNoCloud.NetworkData = networkData
}

return keys, nil
}

// resolveConfigDriveSecrets is looking for CloudInitConfigDriveSource volume source with
// UserDataSecretRef and NetworkDataSecretRef and resolves the secret from the corresponding
// VolumeMount.
//
// Note: when using this function, make sure that your code can access the secret volumes.
func resolveConfigDriveSecrets(vmi *v1.VirtualMachineInstance, secretSourceDir string) (map[string]string, error) {
keys, err := resolveSSHPublicKeys(vmi.Spec.AccessCredentials, secretSourceDir, isConfigDriveAccessCredential)
if err != nil {
return keys, err
}

if err != nil {
return keys, err
}

volume := findCloudInitConfigDriveSecretVolume(vmi.Spec.Volumes)
if volume == nil {
Expand Down Expand Up @@ -382,11 +407,12 @@ func readCloudInitConfigDriveSource(source *v1.CloudInitConfigDriveSource) (*Clo
}, nil
}

func readCloudInitNoCloudMetaData(hostname, instanceId string, instanceType string) *NoCloudMetadata {
func readCloudInitNoCloudMetaData(hostname, instanceId string, instanceType string, keys map[string]string) *NoCloudMetadata {
return &NoCloudMetadata{
InstanceType: instanceType,
InstanceID: instanceId,
LocalHostname: hostname,
PublicSSHKeys: keys,
}
}

Expand Down
30 changes: 26 additions & 4 deletions pkg/cloud-init/cloud-init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,14 +400,36 @@ var _ = Describe("CloudInit", func() {
It("should resolve no-cloud data from volume", func() {
testVolume := createCloudInitSecretRefVolume("test-volume", "test-secret")
vmi := createEmptyVMIWithVolumes([]v1.Volume{*testVolume})

vmi.Spec.AccessCredentials = []v1.AccessCredential{
{
SSHPublicKey: &v1.SSHPublicKeyAccessCredential{
Source: v1.SSHPublicKeyAccessCredentialSource{
Secret: &v1.AccessCredentialSecretSource{
SecretName: "my-pkey",
},
},
PropagationMethod: v1.SSHPublicKeyAccessCredentialPropagationMethod{
NoCloud: &v1.NoCloudSSHPublicKeyAccessCredentialPropagation{},
},
},
},
}

fakeVolumeMountDir("test-volume", map[string]string{
"userdata": "secret-userdata",
"networkdata": "secret-networkdata",
})
err := resolveNoCloudSecrets(vmi, tmpDir)

fakeVolumeMountDir("my-pkey-access-cred", map[string]string{
"somekey": "ssh-1234",
"someotherkey": "ssh-5678",
})
keys, err := resolveNoCloudSecrets(vmi, tmpDir)
Expect(err).To(Not(HaveOccurred()), "could not resolve secret volume")
Expect(testVolume.CloudInitNoCloud.UserData).To(Equal("secret-userdata"))
Expect(testVolume.CloudInitNoCloud.NetworkData).To(Equal("secret-networkdata"))
Expect(keys).To(HaveLen(2))
})

It("should resolve camel-case no-cloud data from volume", func() {
Expand All @@ -417,22 +439,22 @@ var _ = Describe("CloudInit", func() {
"userData": "secret-userdata",
"networkData": "secret-networkdata",
})
err := resolveNoCloudSecrets(vmi, tmpDir)
_, err := resolveNoCloudSecrets(vmi, tmpDir)
Expect(err).To(Not(HaveOccurred()), "could not resolve secret volume")
Expect(testVolume.CloudInitNoCloud.UserData).To(Equal("secret-userdata"))
Expect(testVolume.CloudInitNoCloud.NetworkData).To(Equal("secret-networkdata"))
})

It("should resolve empty no-cloud volume and do nothing", func() {
vmi := createEmptyVMIWithVolumes([]v1.Volume{})
err := resolveNoCloudSecrets(vmi, tmpDir)
_, err := resolveNoCloudSecrets(vmi, tmpDir)
Expect(err).To(Not(HaveOccurred()), "failed to resolve empty volumes")
})

It("should fail if both userdata and network data does not exist", func() {
testVolume := createCloudInitSecretRefVolume("test-volume", "test-secret")
vmi := createEmptyVMIWithVolumes([]v1.Volume{*testVolume})
err := resolveNoCloudSecrets(vmi, tmpDir)
_, err := resolveNoCloudSecrets(vmi, tmpDir)
Expect(err).To(HaveOccurred(), "expected a failure when no sources found")
Expect(err.Error()).To(Equal("no cloud-init data-source found at volume: test-volume"))
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1925,6 +1925,13 @@ func validateAccessCredentials(field *k8sfield.Path, accessCredentials []v1.Acce
return causes
}

hasNoCloudVolume := false
for _, volume := range volumes {
if volume.CloudInitNoCloud != nil {
hasNoCloudVolume = true
break
}
}
hasConfigDriveVolume := false
for _, volume := range volumes {
if volume.CloudInitConfigDrive != nil {
Expand All @@ -1946,6 +1953,18 @@ func validateAccessCredentials(field *k8sfield.Path, accessCredentials []v1.Acce
sourceCount++
}

if accessCred.SSHPublicKey.PropagationMethod.NoCloud != nil {
methodCount++
if !hasNoCloudVolume {
causes = append(causes, metav1.StatusCause{
Type: metav1.CauseTypeFieldValueInvalid,
Message: fmt.Sprintf("%s requires a noCloud volume to exist when the noCloud propagationMethod is in use.", field.Index(idx).String()),
Field: field.Index(idx).Child("sshPublicKey", "propagationMethod").String(),
})

}
}

if accessCred.SSHPublicKey.PropagationMethod.ConfigDrive != nil {
methodCount++
if !hasConfigDriveVolume {
Expand All @@ -1957,6 +1976,7 @@ func validateAccessCredentials(field *k8sfield.Path, accessCredentials []v1.Acce

}
}

if accessCred.SSHPublicKey.PropagationMethod.QemuGuestAgent != nil {

if len(accessCred.SSHPublicKey.PropagationMethod.QemuGuestAgent.Users) == 0 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2694,6 +2694,37 @@ var _ = Describe("Validating VMICreate Admitter", func() {
Expect(causes).To(BeEmpty())
})

It("should reject a noCloud ssh access credential when no noCloud volume exists", func() {
vmi := api.NewMinimalVMI("testvmi")
vmi.Spec.Domain.Devices.Disks = append(vmi.Spec.Domain.Devices.Disks, v1.Disk{
Name: "testdisk",
})

vmi.Spec.Volumes = append(vmi.Spec.Volumes, v1.Volume{
Name: "testdisk",
VolumeSource: v1.VolumeSource{
CloudInitConfigDrive: &v1.CloudInitConfigDriveSource{UserData: " "},
},
})

vmi.Spec.AccessCredentials = []v1.AccessCredential{
{
SSHPublicKey: &v1.SSHPublicKeyAccessCredential{
Source: v1.SSHPublicKeyAccessCredentialSource{
Secret: &v1.AccessCredentialSecretSource{
SecretName: "my-pkey",
},
},
PropagationMethod: v1.SSHPublicKeyAccessCredentialPropagationMethod{
NoCloud: &v1.NoCloudSSHPublicKeyAccessCredentialPropagation{},
},
},
},
}
causes := ValidateVirtualMachineInstanceSpec(k8sfield.NewPath("fake"), &vmi.Spec, config)
Expect(causes).To(HaveLen(1))
Expect(causes[0].Message).To(ContainSubstring("requires a noCloud volume to exist"))
})
It("should reject a configDrive ssh access credential when no configDrive volume exists", func() {
vmi := api.NewMinimalVMI("testvmi")
vmi.Spec.Domain.Devices.Disks = append(vmi.Spec.Domain.Devices.Disks, v1.Disk{
Expand Down Expand Up @@ -2723,6 +2754,7 @@ var _ = Describe("Validating VMICreate Admitter", func() {
}
causes := ValidateVirtualMachineInstanceSpec(k8sfield.NewPath("fake"), &vmi.Spec, config)
Expect(causes).To(HaveLen(1))
Expect(causes[0].Message).To(ContainSubstring("requires a configDrive volume to exist"))
})
It("should reject a ssh access credential without a source", func() {
vmi := api.NewMinimalVMI("testvmi")
Expand Down

0 comments on commit 9a94b8c

Please sign in to comment.