diff --git a/Makefile b/Makefile index 15442735f..477976712 100644 --- a/Makefile +++ b/Makefile @@ -186,7 +186,7 @@ wait-and-get-secret: $(KUBECTL) get secrets $(CLUSTER_NAME)-kubeconfig -o json | jq -r .data.value | base64 --decode > $(WORKER_CLUSTER_KUBECONFIG) ${TIMEOUT} 15m bash -c "while ! $(KUBECTL) --kubeconfig=$(WORKER_CLUSTER_KUBECONFIG) get nodes | grep control-plane; do sleep 1; done" -install-cilium-in-wl-cluster: +install-cilium-in-wl-cluster: $(HELM) # Deploy cilium $(HELM) repo add cilium https://helm.cilium.io/ $(HELM) repo update cilium diff --git a/api/v1beta1/hetznerbaremetalmachine_types.go b/api/v1beta1/hetznerbaremetalmachine_types.go index fa31eb3ce..055c0fe3a 100644 --- a/api/v1beta1/hetznerbaremetalmachine_types.go +++ b/api/v1beta1/hetznerbaremetalmachine_types.go @@ -316,6 +316,9 @@ func (bmMachine *HetznerBareMetalMachine) SetFailure(reason capierrors.MachineSt // GetImageSuffix tests whether the suffix is known and outputs it if yes. Otherwise it returns an error. func GetImageSuffix(url string) (string, error) { + if strings.HasPrefix(url, "oci://") { + return "tar.gz", nil + } for _, suffix := range []ImageType{ ImageTypeTar, ImageTypeTarGz, diff --git a/pkg/services/baremetal/client/ssh/download-from-oci.sh b/pkg/services/baremetal/client/ssh/download-from-oci.sh new file mode 100755 index 000000000..74030b6b7 --- /dev/null +++ b/pkg/services/baremetal/client/ssh/download-from-oci.sh @@ -0,0 +1,85 @@ +#!/bin/bash +set -euo pipefail + +image="${1:-}" +outfile="${2:-}" + +function usage { + echo "$0 image outfile." + echo " Download a machine image from a container registry" + echo " image: for example ghcr.io/foo/bar/node-images/staging/my-machine-image:v9" + echo " outfile: Created file. Usualy with file extensions '.tgz'" + echo " If the oci registry needs a token, then the script uses OCI_REGISTRY_AUTH_TOKEN (if set)" + echo " Example of OCI_REGISTRY_AUTH_TOKEN: github:ghp_SN51...." + echo +} +if [ -z "$outfile" ]; then + usage + exit 1 +fi +OCI_REGISTRY_AUTH_TOKEN="${OCI_REGISTRY_AUTH_TOKEN:-}" # github:$GITHUB_TOKEN + +# Extract registry +registry="${image%%/*}" + +# Extract scope and tag +remainder="${image#*/}" +scope="${remainder%:*}" +tag="${remainder##*:}" + +if [[ -z "$registry" || -z "$scope" || -z "$tag" ]]; then + echo "failed to parse registry, scope and tag from image" + echo "image=$image" + echo "registry=$registry" + echo "scope=$scope" + echo "tag=$tag" + exit 1 +fi + +function download_with_token { + echo "download with token (OCI_REGISTRY_AUTH_TOKEN set)" + if [[ "$OCI_REGISTRY_AUTH_TOKEN" != *:* ]]; then + echo "OCI_REGISTRY_AUTH_TOKEN needs to contain a ':' (user:token)" + exit 1 + fi + + token=$(curl -fsSL -u "$OCI_REGISTRY_AUTH_TOKEN" "https://${registry}/token?scope=repository:$scope:pull" | jq -r '.token') + if [ -z "$token" ]; then + echo "Failed to get token for container registry" + exit 1 + fi + + echo "Login to $registry was successful" + + digest=$(curl -sSL -H "Authorization: Bearer $token" -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + "https://${registry}/v2/${scope}/manifests/${tag}" | jq -r '.layers[0].digest') + + if [ -z "$digest" ]; then + echo "Failed to get digest from container registry" + exit 1 + fi + + echo "Start download of $image" + curl -fsSL -H "Authorization: Bearer $token" \ + "https://${registry}/v2/${scope}/blobs/$digest" >"$outfile" +} + +function download_without_token { + echo "download without token (OCI_REGISTRY_AUTH_TOKEN empty)" + digest=$(curl -sSL -H "Accept: application/vnd.oci.image.manifest.v1+json" \ + "https://${registry}/v2/${scope}/manifests/${tag}" | jq -r '.layers[0].digest') + + if [ -z "$digest" ]; then + echo "Failed to get digest from container registry" + exit 1 + fi + + echo "Start download of $image" + curl -fsSL "https://${registry}/v2/${scope}/blobs/$digest" >"$outfile" +} + +if [ -z "$OCI_REGISTRY_AUTH_TOKEN" ]; then + download_without_token +else + download_with_token +fi diff --git a/pkg/services/baremetal/client/ssh/ssh_client.go b/pkg/services/baremetal/client/ssh/ssh_client.go index 72f63961a..7e7f90669 100644 --- a/pkg/services/baremetal/client/ssh/ssh_client.go +++ b/pkg/services/baremetal/client/ssh/ssh_client.go @@ -18,6 +18,8 @@ limitations under the License. package sshclient import ( + _ "embed" + "bufio" "bytes" "encoding/base64" @@ -35,6 +37,9 @@ const ( sshTimeOut time.Duration = 5 * time.Second ) +//go:embed download-from-oci.sh +var downloadFromOciShellScript string + var ( // ErrCommandExitedWithoutExitSignal means the ssh command exited unplanned. ErrCommandExitedWithoutExitSignal = errors.New("wait: remote command exited without exit status or exit signal") @@ -221,7 +226,20 @@ EOF`, data)) // DownloadImage implements the DownloadImage method of the SSHClient interface. func (c *sshClient) DownloadImage(path, url string) Output { - return c.runSSH(fmt.Sprintf(`curl -sLo "%q" "%q"`, path, url)) + fmt.Printf("########################### Start DownloadImage %s %s\n", path, url) + if !strings.HasPrefix(url, "oci://") { + return c.runSSH(fmt.Sprintf(`curl -sLo "%q" "%q"`, path, url)) + } + out := c.runSSH(fmt.Sprintf(`cat << 'ENDOFSCRIPT' > /root/download-from-oci.sh +%s +ENDOFSCRIPT +chmod a+rx /root/download-from-oci.sh +OCI_REGISTRY_AUTH_TOKEN=%s /root/download-from-oci.sh %s %s`, downloadFromOciShellScript, + os.Getenv("OCI_REGISTRY_AUTH_TOKEN"), + strings.TrimPrefix(url, "oci://"), path)) + fmt.Printf("########################### End DownloadImage err=%v\n %s\n %s\n", out.Err, out.StdOut, out.StdErr) + return out + } // CreatePostInstallScript implements the CreatePostInstallScript method of the SSHClient interface. diff --git a/pkg/services/baremetal/host/host.go b/pkg/services/baremetal/host/host.go index 330936a64..270ad308b 100644 --- a/pkg/services/baremetal/host/host.go +++ b/pkg/services/baremetal/host/host.go @@ -1011,7 +1011,15 @@ func (s *Service) createAutoSetupInput(sshClient sshclient.Client) (autoSetupInp if needsDownload { out := sshClient.DownloadImage(imagePath, image.URL) if err := handleSSHError(out); err != nil { - return autoSetupInput{}, actionError{err: fmt.Errorf("failed to download image: %w", err)} + // TODO: this could fail like this, if the registry requires auth, but not token is given. + // TODO: This should be visible in the Conditions somehow. + // 15:40:37 ERROR "Reconciler error" controller/controller.go:324 {'HetznerBareMetalHost': .. + // 'error': 'failed to reconcile HetznerBareMetalHost default/test-bm-gpu: action "image-installing" + // failed: failed to download image: \ndownload without token (OCI_REGISTRY_AUTH_TOKEN)\n + // Start download of + // ghcr.io/syself/autopilot/node-images/staging/hetzner-apalla-1-27-workeramd64baremetal:v9-beta-1\n + // curl: (22) The requested URL returned error: 404\n failed to perform ssh command: Process exited with status 22'} + return autoSetupInput{}, actionError{err: fmt.Errorf("failed to download image: %s %s %w", out.StdOut, out.StdErr, err)} } }