diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..ebe90cc0 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,78 @@ +name: Deploy Assets + +on: + workflow_dispatch: + pull_request: + paths: + - ".github/workflows/deploy.yml" + - ".gitignore" + - "deploy/**" + push: + paths: + - ".github/workflows/deploy.yml" + - ".gitignore" + - "deploy/**" + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v6 + with: + go-version: "1.25.4" + cache: false + + - name: Install validation tools + run: | + set -euo pipefail + python3 -m pip install --user cfn-lint + + - name: Validate CloudFormation + run: | + cfn-lint deploy/aws/cloudformation/template.yaml + go test ./deploy/aws/cloudformation + + publish-cloudformation: + runs-on: ubuntu-latest + needs: validate + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' + environment: aws-publish + permissions: + contents: read + id-token: write + env: + AWS_REGION: us-east-1 + CLOUDFORMATION_TEMPLATE_BUCKET: kernel-hypeman-cloudformation-prod + CLOUDFORMATION_TEMPLATE_KEY: v1/hypeman/template.yaml + CLOUDFORMATION_TEMPLATE_SOURCE: deploy/aws/cloudformation/template.yaml + steps: + - uses: actions/checkout@v4 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::613957054632:role/github-actions-hypeman-cloudformation-publisher + aws-region: ${{ env.AWS_REGION }} + + - name: Publish CloudFormation template + run: | + set -euo pipefail + + aws s3 cp \ + "$CLOUDFORMATION_TEMPLATE_SOURCE" \ + "s3://$CLOUDFORMATION_TEMPLATE_BUCKET/$CLOUDFORMATION_TEMPLATE_KEY" \ + --content-type application/x-yaml \ + --cache-control no-cache + + aws s3api head-object \ + --bucket "$CLOUDFORMATION_TEMPLATE_BUCKET" \ + --key "$CLOUDFORMATION_TEMPLATE_KEY" \ + >/dev/null + + curl -fsSL \ + "https://$CLOUDFORMATION_TEMPLATE_BUCKET.s3.$AWS_REGION.amazonaws.com/$CLOUDFORMATION_TEMPLATE_KEY" \ + >/dev/null diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 00000000..a7e2bc32 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,102 @@ +# Deploy Hypeman + +This directory contains maintained deployment assets for running Hypeman outside local development. + +## AWS Quickstart + +The fastest path is the hosted CloudFormation template. It creates one EC2 instance with nested virtualization enabled, installs Hypeman during instance bootstrap, exposes the Hypeman API only to the CIDR you choose, and provisions an encrypted XFS data volume mounted at `/var/lib/hypeman`. + +[![Launch Stack](https://s3.amazonaws.com/cloudformation-examples/cloudformation-launch-stack.png)](https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/create/review?templateURL=https%3A%2F%2Fkernel-hypeman-cloudformation-prod.s3.us-east-1.amazonaws.com%2Fv1%2Fhypeman%2Ftemplate.yaml&stackName=hypeman) + +Use `us-east-1` for the published template. Choose a VPC and subnet, set `AllowedApiCidr` to your current public IP `/32` or trusted VPN CIDR, keep SSH disabled unless you need it, then create the stack. + +Useful stack outputs: + +| Output | Purpose | +| --- | --- | +| `HypemanEndpoint` | Base URL for remote Hypeman API access | +| `SsmSessionCommand` | Session Manager command for host access | +| `CreateTokenCommand` | Command that generates a JWT on the instance | +| `InstanceId` | EC2 instance running Hypeman | + +To delete the deployment, delete the CloudFormation stack. The EC2 instance and attached stack-managed volumes are deleted with it. + +```sh +aws cloudformation delete-stack \ + --region us-east-1 \ + --stack-name hypeman +``` + +## Use Hypeman + +After the stack reaches `CREATE_COMPLETE`, run the `SsmSessionCommand` output and generate a token: + +```sh +sudo hypeman-create-token remote-user 8760h +``` + +On your local machine, install the CLI and point it at the `HypemanEndpoint` output: + +```sh +curl -fsSL https://get.hypeman.sh/cli | bash + +mkdir -p ~/.config/hypeman +cat > ~/.config/hypeman/cli.yaml <:8080 +api_key: "" +EOF + +hypeman ps +``` + +Build, push, and run a sandbox image: + +```sh +mkdir -p /tmp/hypeman-claude-code +cat > /tmp/hypeman-claude-code/Dockerfile <<'EOF' +FROM node:22-bookworm-slim +RUN npm install -g @anthropic-ai/claude-code +WORKDIR /workspace +CMD ["sleep", "infinity"] +EOF + +docker build -t local/claude-code-sandbox:latest /tmp/hypeman-claude-code +hypeman push local/claude-code-sandbox:latest sandbox/claude-code:latest + +until hypeman image get sandbox/claude-code:latest | grep -qi ready; do + sleep 2 +done + +hypeman run --name claude-code-sandbox sandbox/claude-code:latest +hypeman exec claude-code-sandbox -- claude --version +``` + +Clean up the sandbox when you are done: + +```sh +hypeman stop claude-code-sandbox +hypeman rm claude-code-sandbox +``` + +## CloudFormation Source + +The source template lives at `deploy/aws/cloudformation/template.yaml`. The `Deploy Assets` GitHub workflow validates it on pull requests and publishes it from `main` to: + +```text +https://kernel-hypeman-cloudformation-prod.s3.us-east-1.amazonaws.com/v1/hypeman/template.yaml +``` + +## Defaults + +| Setting | Default | +| --- | --- | +| Region | `us-east-1` | +| Instance type | `c8i.2xlarge` | +| Hypeman API port | `8080` | +| Admin access | AWS Systems Manager Session Manager | +| SSH | Disabled unless explicitly enabled | +| Root volume | 30 GiB encrypted EBS | +| Hypeman data volume | 100 GiB encrypted EBS, formatted XFS at `/var/lib/hypeman` | +| Hypeman version | Latest release with a matching artifact | + +The deployment expects an Intel C8i, M8i, or R8i instance type with EC2 nested virtualization support. diff --git a/deploy/aws/cloudformation/parameters.example.json b/deploy/aws/cloudformation/parameters.example.json new file mode 100644 index 00000000..d8da7279 --- /dev/null +++ b/deploy/aws/cloudformation/parameters.example.json @@ -0,0 +1,18 @@ +[ + { + "ParameterKey": "VpcId", + "ParameterValue": "vpc-xxxxxxxx" + }, + { + "ParameterKey": "SubnetId", + "ParameterValue": "subnet-xxxxxxxx" + }, + { + "ParameterKey": "AllowedApiCidr", + "ParameterValue": "203.0.113.10/32" + }, + { + "ParameterKey": "InstanceType", + "ParameterValue": "c8i.2xlarge" + } +] diff --git a/deploy/aws/cloudformation/template.yaml b/deploy/aws/cloudformation/template.yaml new file mode 100644 index 00000000..03d09dda --- /dev/null +++ b/deploy/aws/cloudformation/template.yaml @@ -0,0 +1,516 @@ +AWSTemplateFormatVersion: "2010-09-09" +Description: Hypeman host on EC2 with nested virtualization enabled. + +Metadata: + AWS::CloudFormation::Interface: + ParameterGroups: + - Label: + default: Network + Parameters: + - VpcId + - SubnetId + - AllowedApiCidr + - ApiPort + - Label: + default: Instance + Parameters: + - InstanceType + - RootVolumeSize + - DataVolumeSize + - AmiSsmParameter + - Label: + default: Access + Parameters: + - EnableSSH + - AllowedSshCidr + - KeyName + - Label: + default: Hypeman + Parameters: + - HypemanVersion + - HypemanCliVersion + - HypemanBranch + ParameterLabels: + VpcId: + default: VPC + SubnetId: + default: Subnet + InstanceType: + default: EC2 instance type + AllowedApiCidr: + default: Hypeman API access CIDR + ApiPort: + default: Hypeman API port + EnableSSH: + default: Enable SSH + AllowedSshCidr: + default: SSH access CIDR + KeyName: + default: EC2 key pair + RootVolumeSize: + default: Root volume size + DataVolumeSize: + default: Hypeman data volume size + HypemanVersion: + default: Hypeman release + HypemanBranch: + default: Hypeman source branch + HypemanCliVersion: + default: Hypeman CLI release + AmiSsmParameter: + default: Ubuntu AMI SSM parameter + +Parameters: + VpcId: + Type: AWS::EC2::VPC::Id + Description: Existing VPC for the Hypeman host. + SubnetId: + Type: AWS::EC2::Subnet::Id + Description: Existing subnet for the Hypeman host. The subnet needs outbound internet or VPC endpoint access for package downloads, AWS APIs, and image pulls. + InstanceType: + Type: String + Default: c8i.2xlarge + AllowedPattern: "^(c8i|m8i|r8i)\\.(large|xlarge|2xlarge|4xlarge|8xlarge|12xlarge|16xlarge|24xlarge|32xlarge|48xlarge|96xlarge)$" + Description: Intel C8i, M8i, or R8i instance type that supports EC2 nested virtualization. The default c8i.2xlarge has enough memory for the quickstart workload. + AllowedApiCidr: + Type: String + Default: 127.0.0.1/32 + Description: Client CIDR allowed to reach the Hypeman API port. Use your current public IP /32 or a trusted VPN CIDR; avoid 0.0.0.0/0. + AllowedPattern: "^([0-9]{1,3}\\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$" + ApiPort: + Type: Number + Default: 8080 + MinValue: 1 + MaxValue: 65535 + Description: Hypeman API port exposed to AllowedApiCidr. + EnableSSH: + Type: String + Default: "false" + AllowedValues: ["true", "false"] + Description: Open SSH from AllowedSshCidr and pass KeyName to the instance. Session Manager is available by default, so SSH is usually unnecessary. + AllowedSshCidr: + Type: String + Default: 127.0.0.1/32 + Description: CIDR allowed to reach SSH when EnableSSH is true. + AllowedPattern: "^([0-9]{1,3}\\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$" + KeyName: + Type: String + Default: "" + Description: Optional EC2 key pair name. Only used when EnableSSH is true. + RootVolumeSize: + Type: Number + Default: 30 + MinValue: 20 + MaxValue: 16384 + Description: Root EBS volume size in GiB. + DataVolumeSize: + Type: Number + Default: 100 + MinValue: 50 + MaxValue: 16384 + Description: Hypeman data EBS volume size in GiB. This volume is formatted as XFS and mounted at /var/lib/hypeman. + HypemanVersion: + Type: String + Default: latest + Description: Hypeman API release tag, or latest. Ignored when HypemanBranch is set. + HypemanBranch: + Type: String + Default: "" + Description: Optional Hypeman git branch to build from source for development testing. Leave empty for release installs. + HypemanCliVersion: + Type: String + Default: latest + Description: Hypeman CLI release tag, or latest. + AmiSsmParameter: + Type: AWS::SSM::Parameter::Value + Default: /aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id + Description: SSM public parameter containing the Ubuntu AMI ID. + +Conditions: + UseSSH: !Equals [!Ref EnableSSH, "true"] + +Resources: + HypemanSecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + GroupDescription: Hypeman access + VpcId: !Ref VpcId + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: !Ref ApiPort + ToPort: !Ref ApiPort + CidrIp: !Ref AllowedApiCidr + Description: Hypeman API + - !If + - UseSSH + - IpProtocol: tcp + FromPort: 22 + ToPort: 22 + CidrIp: !Ref AllowedSshCidr + Description: SSH + - !Ref AWS::NoValue + SecurityGroupEgress: + - IpProtocol: -1 + CidrIp: 0.0.0.0/0 + Description: Outbound access for package, image, and AWS API downloads + Tags: + - Key: Name + Value: !Sub ${AWS::StackName}-hypeman + + HypemanInstanceRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: ec2.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore + Policies: + - PolicyName: hypeman-describe-self + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - ec2:DescribeInstances + - ec2:DescribeInstanceStatus + Resource: "*" + + HypemanInstanceProfile: + Type: AWS::IAM::InstanceProfile + Properties: + Roles: + - !Ref HypemanInstanceRole + + # CloudFormation's typed EC2 resources do not expose CpuOptions.NestedVirtualization yet. + # This helper creates only the launch template that carries that EC2 API option; + # the Hypeman EC2 instance itself remains a normal stack-managed resource. + NestedVirtualizationLaunchTemplateRole: + Type: AWS::IAM::Role + Properties: + AssumeRolePolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Principal: + Service: lambda.amazonaws.com + Action: sts:AssumeRole + ManagedPolicyArns: + - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole + Policies: + - PolicyName: hypeman-nested-virtualization-launch-template + PolicyDocument: + Version: "2012-10-17" + Statement: + - Effect: Allow + Action: + - ec2:CreateLaunchTemplate + - ec2:DeleteLaunchTemplate + - ec2:DescribeLaunchTemplates + - ec2:CreateTags + Resource: "*" + + NestedVirtualizationLaunchTemplateFunction: + Type: AWS::Lambda::Function + Properties: + Runtime: python3.12 + Handler: index.handler + Timeout: 60 + Role: !GetAtt NestedVirtualizationLaunchTemplateRole.Arn + Code: + ZipFile: | + import json + import os + import traceback + import urllib.parse + import urllib.request + import xml.etree.ElementTree as ET + from urllib.error import HTTPError + + from botocore.auth import SigV4Auth + from botocore.awsrequest import AWSRequest + from botocore.session import Session + + def send(event, context, status, data, physical_id=None, reason=None): + body = { + "Status": status, + "Reason": reason or ("See CloudWatch Logs: " + context.log_stream_name), + "PhysicalResourceId": physical_id or context.log_stream_name, + "StackId": event["StackId"], + "RequestId": event["RequestId"], + "LogicalResourceId": event["LogicalResourceId"], + "NoEcho": False, + "Data": data, + } + encoded = json.dumps(body).encode("utf-8") + req = urllib.request.Request( + event["ResponseURL"], + data=encoded, + headers={"content-type": "", "content-length": str(len(encoded))}, + method="PUT", + ) + urllib.request.urlopen(req, timeout=10).read() + + def ec2_query(params): + region = os.environ["AWS_REGION"] + endpoint = f"https://ec2.{region}.amazonaws.com/" + body = urllib.parse.urlencode(params).encode("utf-8") + req = AWSRequest( + method="POST", + url=endpoint, + data=body, + headers={"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"}, + ) + credentials = Session().get_credentials().get_frozen_credentials() + SigV4Auth(credentials, "ec2", region).add_auth(req) + prepared = req.prepare() + http_req = urllib.request.Request( + endpoint, + data=body, + headers=dict(prepared.headers), + method="POST", + ) + try: + with urllib.request.urlopen(http_req, timeout=60) as res: + return res.read() + except HTTPError as err: + detail = err.read().decode("utf-8", errors="replace") + raise RuntimeError(f"EC2 query failed with HTTP {err.code}: {detail}") from err + + def launch_template_name(event): + stack_uuid = event["StackId"].rsplit("/", 1)[-1] + return f"{event['ResourceProperties']['NamePrefix']}-{stack_uuid}" + + def create_launch_template(name): + payload = { + "Action": "CreateLaunchTemplate", + "Version": "2016-11-15", + "LaunchTemplateName": name, + "LaunchTemplateData.CpuOptions.NestedVirtualization": "enabled", + "TagSpecification.1.ResourceType": "launch-template", + "TagSpecification.1.Tag.1.Key": "Name", + "TagSpecification.1.Tag.1.Value": name, + "TagSpecification.1.Tag.2.Key": "hypeman:deployment", + "TagSpecification.1.Tag.2.Value": "aws", + } + xml = ec2_query(payload) + root = ET.fromstring(xml) + launch_template_id = root.find(".//{*}launchTemplateId") + version = root.find(".//{*}latestVersionNumber") + if launch_template_id is None or not launch_template_id.text: + raise RuntimeError("CreateLaunchTemplate response did not include launchTemplateId") + return { + "LaunchTemplateId": launch_template_id.text, + "VersionNumber": version.text if version is not None and version.text else "1", + } + + def delete_launch_template(launch_template_id): + if launch_template_id and launch_template_id.startswith("lt-"): + ec2_query({ + "Action": "DeleteLaunchTemplate", + "Version": "2016-11-15", + "LaunchTemplateId": launch_template_id, + }) + + def handler(event, context): + try: + request_type = event["RequestType"] + physical_id = event.get("PhysicalResourceId") + if request_type == "Delete": + delete_launch_template(physical_id) + send(event, context, "SUCCESS", {}, physical_id=physical_id) + return + if request_type == "Update": + delete_launch_template(physical_id) + data = create_launch_template(launch_template_name(event)) + send(event, context, "SUCCESS", data, physical_id=data["LaunchTemplateId"]) + except Exception as exc: + traceback.print_exc() + send(event, context, "FAILED", {}, physical_id=event.get("PhysicalResourceId"), reason=str(exc)) + + NestedVirtualizationLaunchTemplate: + Type: Custom::NestedVirtualizationLaunchTemplate + Properties: + ServiceToken: !GetAtt NestedVirtualizationLaunchTemplateFunction.Arn + NamePrefix: hypeman + + HypemanHost: + Type: AWS::EC2::Instance + Properties: + LaunchTemplate: + LaunchTemplateId: !GetAtt NestedVirtualizationLaunchTemplate.LaunchTemplateId + Version: !GetAtt NestedVirtualizationLaunchTemplate.VersionNumber + ImageId: !Ref AmiSsmParameter + InstanceType: !Ref InstanceType + SubnetId: !Ref SubnetId + SecurityGroupIds: + - !Ref HypemanSecurityGroup + IamInstanceProfile: !Ref HypemanInstanceProfile + KeyName: !If [UseSSH, !Ref KeyName, !Ref AWS::NoValue] + BlockDeviceMappings: + - DeviceName: /dev/sda1 + Ebs: + VolumeSize: !Ref RootVolumeSize + VolumeType: gp3 + Encrypted: true + DeleteOnTermination: true + - DeviceName: /dev/sdf + Ebs: + VolumeSize: !Ref DataVolumeSize + VolumeType: gp3 + Encrypted: true + DeleteOnTermination: true + Tags: + - Key: Name + Value: !Sub ${AWS::StackName}-hypeman + - Key: hypeman:deployment + Value: aws + UserData: + Fn::Base64: !Sub | + #!/usr/bin/env bash + set -euxo pipefail + exec > >(tee /var/log/hypeman-bootstrap.log | logger -t hypeman-bootstrap -s 2>/dev/console) 2>&1 + + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y ca-certificates curl docker.io e2fsprogs erofs-utils iproute2 iptables jq openssl qemu-system-x86 qemu-utils tar xfsprogs + + setup_hypeman_data_volume() { + local data_device="" + for _ in $(seq 1 60); do + while read -r device type; do + if [ "$type" = "disk" ] && ! lsblk -nrpo MOUNTPOINT "$device" | grep -qx "/"; then + data_device="$device" + break + fi + done < <(lsblk -dnpo NAME,TYPE) + if [ -n "$data_device" ]; then + break + fi + sleep 2 + done + if [ -z "$data_device" ]; then + echo "hypeman data volume was not found" >&2 + exit 1 + fi + if [ "$(blkid -s TYPE -o value "$data_device" 2>/dev/null || true)" != "xfs" ]; then + mkfs.xfs -f "$data_device" + fi + data_uuid="$(blkid -s UUID -o value "$data_device")" + install -d -m 755 /var/lib/hypeman + if ! grep -q " /var/lib/hypeman " /etc/fstab; then + echo "UUID=$data_uuid /var/lib/hypeman xfs defaults,nofail 0 2" >> /etc/fstab + fi + mount /var/lib/hypeman || mount -a + if [ "$(findmnt -n -o FSTYPE /var/lib/hypeman)" != "xfs" ]; then + echo "/var/lib/hypeman is not mounted as xfs" >&2 + exit 1 + fi + } + setup_hypeman_data_volume + + if [ -n "${HypemanBranch}" ]; then + apt-get install -y build-essential git make + curl -fsSL https://go.dev/dl/go1.25.4.linux-amd64.tar.gz | tar -C /usr/local -xzf - + export PATH="/usr/local/go/bin:$PATH" + export HOME=/root + export GOPATH=/root/go + export GOMODCACHE=/root/go/pkg/mod + export GOCACHE=/root/.cache/go-build + install -d -m 755 "$GOPATH" "$GOMODCACHE" "$GOCACHE" + fi + systemctl enable --now docker + + if ! systemctl is-active --quiet snap.amazon-ssm-agent.amazon-ssm-agent && ! systemctl is-active --quiet amazon-ssm-agent; then + snap install amazon-ssm-agent --classic || true + systemctl enable --now snap.amazon-ssm-agent.amazon-ssm-agent || systemctl enable --now amazon-ssm-agent || true + fi + + if [ -n "${HypemanBranch}" ]; then + export BRANCH="${HypemanBranch}" + unset VERSION + elif [ "${HypemanVersion}" = "latest" ]; then + unset VERSION + else + export VERSION="${HypemanVersion}" + fi + if [ "${HypemanCliVersion}" = "latest" ]; then + unset CLI_VERSION + else + export CLI_VERSION="${HypemanCliVersion}" + fi + curl -fsSL https://raw.githubusercontent.com/kernel/hypeman/main/scripts/install.sh | bash + + install -d -m 755 /opt/hypeman/deploy + cat >/usr/local/bin/hypeman-create-token <<'SCRIPT' + #!/usr/bin/env bash + set -euo pipefail + user_id="remote-user" + duration="8760h" + if [ "$#" -ge 1 ]; then + user_id="$1" + fi + if [ "$#" -ge 2 ]; then + duration="$2" + fi + CONFIG_PATH=/etc/hypeman/config.yaml /opt/hypeman/bin/hypeman-token -user-id "$user_id" -duration "$duration" + SCRIPT + chmod 755 /usr/local/bin/hypeman-create-token + + install -d -m 700 /root/.config/hypeman + local_token="$(hypeman-create-token local-cli 8760h)" + cat >/root/.config/hypeman/cli.yaml </opt/hypeman/deploy/validate.sh <<'SCRIPT' + #!/usr/bin/env bash + set -euo pipefail + test -e /dev/kvm + grep -Eq '(^flags|^Features).* (vmx|svm)( |$)' /proc/cpuinfo + test "$(findmnt -n -o FSTYPE /var/lib/hypeman)" = "xfs" + systemctl is-active --quiet hypeman + token="$(hypeman-create-token validation 1h)" + curl -fsS -H "Authorization: Bearer $token" http://127.0.0.1:${ApiPort}/health >/dev/null + echo "hypeman aws validation passed" + SCRIPT + chmod 755 /opt/hypeman/deploy/validate.sh + + for _ in $(seq 1 60); do + if /opt/hypeman/deploy/validate.sh; then + exit 0 + fi + sleep 5 + done + /opt/hypeman/deploy/validate.sh + +Outputs: + InstanceId: + Description: EC2 instance running Hypeman. + Value: !Ref HypemanHost + PublicIp: + Description: Public IP address, if the subnet assigns one. + Value: !GetAtt HypemanHost.PublicIp + PrivateIp: + Description: Private IP address. + Value: !GetAtt HypemanHost.PrivateIp + HypemanEndpoint: + Description: Hypeman API endpoint. + Value: !Sub + - http://${Host}:${Port} + - Host: !GetAtt HypemanHost.PublicIp + Port: !Ref ApiPort + SsmSessionCommand: + Description: Command to start a Session Manager shell. + Value: !Sub + - aws ssm start-session --region ${AWS::Region} --target ${InstanceId} + - InstanceId: !Ref HypemanHost + CreateTokenCommand: + Description: Command to generate a JWT on the instance. + Value: sudo hypeman-create-token remote-user 8760h diff --git a/deploy/aws/cloudformation/template_test.go b/deploy/aws/cloudformation/template_test.go new file mode 100644 index 00000000..b9473bb6 --- /dev/null +++ b/deploy/aws/cloudformation/template_test.go @@ -0,0 +1,247 @@ +package cloudformation_test + +import ( + "os" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestQuickstartParameters(t *testing.T) { + template := loadTemplate(t) + root := requireMapping(t, template) + + parameters := requireMapping(t, requireField(t, root, "Parameters")) + assertDefault(t, parameters, "InstanceType", "c8i.2xlarge") + assertDefault(t, parameters, "AllowedApiCidr", "127.0.0.1/32") + assertDefault(t, parameters, "ApiPort", "8080") + assertDefault(t, parameters, "EnableSSH", "false") + assertDefault(t, parameters, "AllowedSshCidr", "127.0.0.1/32") + assertDefault(t, parameters, "RootVolumeSize", "30") + assertDefault(t, parameters, "DataVolumeSize", "100") + assertDefault(t, parameters, "HypemanVersion", "latest") + assertDefault(t, parameters, "HypemanCliVersion", "latest") + + instanceType := requireMapping(t, parameters["InstanceType"]) + assertContains(t, scalar(t, instanceType["AllowedPattern"]), "c8i|m8i|r8i") + assertContains(t, scalar(t, instanceType["Description"]), "nested virtualization") + + apiCidr := requireMapping(t, parameters["AllowedApiCidr"]) + assertContains(t, scalar(t, apiCidr["Description"]), "current public IP /32") + assertContains(t, scalar(t, apiCidr["Description"]), "avoid 0.0.0.0/0") + + metadata := requireMapping(t, requireField(t, root, "Metadata")) + cfnInterface := requireMapping(t, requireField(t, metadata, "AWS::CloudFormation::Interface")) + groups := requireSequence(t, requireField(t, cfnInterface, "ParameterGroups")) + groupNames := make(map[string]bool) + for _, group := range groups.Content { + label := requireMapping(t, requireField(t, requireMapping(t, group), "Label")) + groupNames[scalar(t, requireField(t, label, "default"))] = true + } + for _, name := range []string{"Network", "Instance", "Access", "Hypeman"} { + if !groupNames[name] { + t.Fatalf("missing CloudFormation parameter group %q", name) + } + } +} + +func TestCloudFormationLaunchContract(t *testing.T) { + template := loadTemplate(t) + root := requireMapping(t, template) + resources := requireMapping(t, requireField(t, root, "Resources")) + + securityGroup := requireMapping(t, requireField(t, resources, "HypemanSecurityGroup")) + sgProperties := requireMapping(t, requireField(t, securityGroup, "Properties")) + ingress := requireSequence(t, requireField(t, sgProperties, "SecurityGroupIngress")) + if len(ingress.Content) != 2 { + t.Fatalf("expected API ingress and conditional SSH ingress, got %d entries", len(ingress.Content)) + } + + apiIngress := requireMapping(t, ingress.Content[0]) + assertRef(t, requireField(t, apiIngress, "FromPort"), "ApiPort") + assertRef(t, requireField(t, apiIngress, "ToPort"), "ApiPort") + assertRef(t, requireField(t, apiIngress, "CidrIp"), "AllowedApiCidr") + + sshIngress := ingress.Content[1] + if sshIngress.Tag != "!If" { + t.Fatalf("expected SSH ingress to be conditional !If, got %s", sshIngress.Tag) + } + sshIf := requireSequence(t, sshIngress) + if got := scalar(t, sshIf.Content[0]); got != "UseSSH" { + t.Fatalf("expected SSH condition UseSSH, got %q", got) + } + sshRule := requireMapping(t, sshIf.Content[1]) + if got := scalar(t, requireField(t, sshRule, "FromPort")); got != "22" { + t.Fatalf("expected SSH port 22, got %q", got) + } + assertRef(t, requireField(t, sshRule, "CidrIp"), "AllowedSshCidr") + + launchTemplate := requireMapping(t, requireField(t, resources, "NestedVirtualizationLaunchTemplate")) + if got := scalar(t, requireField(t, launchTemplate, "Type")); got != "Custom::NestedVirtualizationLaunchTemplate" { + t.Fatalf("NestedVirtualizationLaunchTemplate type = %q, want Custom::NestedVirtualizationLaunchTemplate", got) + } + + launchTemplateFunction := requireMapping(t, requireField(t, resources, "NestedVirtualizationLaunchTemplateFunction")) + code := requireMapping(t, requireField(t, requireMapping(t, requireField(t, launchTemplateFunction, "Properties")), "Code")) + zipFile := scalar(t, requireField(t, code, "ZipFile")) + assertContains(t, zipFile, `"Action": "CreateLaunchTemplate"`) + assertContains(t, zipFile, `"LaunchTemplateData.CpuOptions.NestedVirtualization": "enabled"`) + + host := requireMapping(t, requireField(t, resources, "HypemanHost")) + if got := scalar(t, requireField(t, host, "Type")); got != "AWS::EC2::Instance" { + t.Fatalf("HypemanHost type = %q, want AWS::EC2::Instance", got) + } + hostProperties := requireMapping(t, requireField(t, host, "Properties")) + hostLaunchTemplate := requireMapping(t, requireField(t, hostProperties, "LaunchTemplate")) + assertGetAtt(t, requireField(t, hostLaunchTemplate, "LaunchTemplateId"), "NestedVirtualizationLaunchTemplate.LaunchTemplateId") + assertGetAtt(t, requireField(t, hostLaunchTemplate, "Version"), "NestedVirtualizationLaunchTemplate.VersionNumber") + + blockDeviceMappings := requireSequence(t, requireField(t, hostProperties, "BlockDeviceMappings")) + if len(blockDeviceMappings.Content) != 2 { + t.Fatalf("expected root and Hypeman data block device mappings, got %d", len(blockDeviceMappings.Content)) + } + dataDevice := requireMapping(t, blockDeviceMappings.Content[1]) + if got := scalar(t, requireField(t, dataDevice, "DeviceName")); got != "/dev/sdf" { + t.Fatalf("data device name = %q, want /dev/sdf", got) + } + dataEBS := requireMapping(t, requireField(t, dataDevice, "Ebs")) + assertRef(t, requireField(t, dataEBS, "VolumeSize"), "DataVolumeSize") + + userData := nodeText(requireField(t, hostProperties, "UserData")) + assertContains(t, userData, "curl -fsSL https://raw.githubusercontent.com/kernel/hypeman/main/scripts/install.sh | bash") + assertContains(t, userData, "xfsprogs") + assertContains(t, userData, "mkfs.xfs -f") + assertContains(t, userData, "/var/lib/hypeman") + assertContains(t, userData, `findmnt -n -o FSTYPE /var/lib/hypeman`) + assertContains(t, userData, "/usr/local/bin/hypeman-create-token") + assertContains(t, userData, "/opt/hypeman/deploy/validate.sh") + assertContains(t, userData, "CONFIG_PATH=/etc/hypeman/config.yaml /opt/hypeman/bin/hypeman-token") + assertContains(t, userData, "http://127.0.0.1:${ApiPort}/health") + assertContains(t, userData, "GOMODCACHE=/root/go/pkg/mod") +} + +func TestQuickstartOutputs(t *testing.T) { + template := loadTemplate(t) + root := requireMapping(t, template) + outputs := requireMapping(t, requireField(t, root, "Outputs")) + + for _, name := range []string{ + "InstanceId", + "PublicIp", + "PrivateIp", + "HypemanEndpoint", + "SsmSessionCommand", + "CreateTokenCommand", + } { + if _, ok := outputs[name]; !ok { + t.Fatalf("missing output %q", name) + } + } + + assertContains(t, scalar(t, requireField(t, requireMapping(t, outputs["HypemanEndpoint"]), "Description")), "Hypeman API") + assertContains(t, scalar(t, requireField(t, requireMapping(t, outputs["SsmSessionCommand"]), "Description")), "Session Manager") + assertContains(t, scalar(t, requireField(t, requireMapping(t, outputs["CreateTokenCommand"]), "Value")), "hypeman-create-token") +} + +func loadTemplate(t *testing.T) *yaml.Node { + t.Helper() + + raw, err := os.ReadFile("template.yaml") + if err != nil { + t.Fatal(err) + } + var doc yaml.Node + if err := yaml.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + if len(doc.Content) != 1 { + t.Fatalf("expected one YAML document, got %d", len(doc.Content)) + } + return doc.Content[0] +} + +func assertDefault(t *testing.T, parameters map[string]*yaml.Node, name, want string) { + t.Helper() + + parameter := requireMapping(t, requireField(t, parameters, name)) + if got := scalar(t, requireField(t, parameter, "Default")); got != want { + t.Fatalf("parameter %s default = %q, want %q", name, got, want) + } +} + +func assertRef(t *testing.T, node *yaml.Node, want string) { + t.Helper() + + if node.Kind != yaml.ScalarNode || node.Tag != "!Ref" || node.Value != want { + t.Fatalf("expected !Ref %s, got kind=%v tag=%q value=%q", want, node.Kind, node.Tag, node.Value) + } +} + +func assertGetAtt(t *testing.T, node *yaml.Node, want string) { + t.Helper() + + if node.Kind != yaml.ScalarNode || node.Tag != "!GetAtt" || node.Value != want { + t.Fatalf("expected !GetAtt %s, got kind=%v tag=%q value=%q", want, node.Kind, node.Tag, node.Value) + } +} + +func assertContains(t *testing.T, got, want string) { + t.Helper() + + if !strings.Contains(got, want) { + t.Fatalf("expected %q to contain %q", got, want) + } +} + +func requireField(t *testing.T, mapping map[string]*yaml.Node, key string) *yaml.Node { + t.Helper() + + value, ok := mapping[key] + if !ok { + t.Fatalf("missing field %q", key) + } + return value +} + +func requireMapping(t *testing.T, node *yaml.Node) map[string]*yaml.Node { + t.Helper() + + if node.Kind != yaml.MappingNode { + t.Fatalf("expected mapping node, got kind=%v tag=%q value=%q", node.Kind, node.Tag, node.Value) + } + result := make(map[string]*yaml.Node, len(node.Content)/2) + for i := 0; i < len(node.Content); i += 2 { + result[node.Content[i].Value] = node.Content[i+1] + } + return result +} + +func requireSequence(t *testing.T, node *yaml.Node) *yaml.Node { + t.Helper() + + if node.Kind != yaml.SequenceNode { + t.Fatalf("expected sequence node, got kind=%v tag=%q value=%q", node.Kind, node.Tag, node.Value) + } + return node +} + +func scalar(t *testing.T, node *yaml.Node) string { + t.Helper() + + if node.Kind != yaml.ScalarNode { + t.Fatalf("expected scalar node, got kind=%v tag=%q", node.Kind, node.Tag) + } + return node.Value +} + +func nodeText(node *yaml.Node) string { + if node.Kind == yaml.ScalarNode { + return node.Value + } + var parts []string + for _, child := range node.Content { + parts = append(parts, nodeText(child)) + } + return strings.Join(parts, "\n") +} diff --git a/lib/middleware/oapi_auth.go b/lib/middleware/oapi_auth.go index d72ef405..b22ab39c 100644 --- a/lib/middleware/oapi_auth.go +++ b/lib/middleware/oapi_auth.go @@ -394,7 +394,7 @@ func JwtAuth(jwtSecret string) func(http.Handler) http.Handler { "repos", registryClaims.Repositories, "scope", registryClaims.Scope) ctx := context.WithValue(r.Context(), userIDKey, "builder-"+registryClaims.BuildID) - next.ServeHTTP(w, r.WithContext(ctx)) + next.ServeHTTP(w, requestWithoutAuthorization(r, ctx)) return } log.DebugContext(r.Context(), "registry token validation failed", "error", err) @@ -420,7 +420,7 @@ func JwtAuth(jwtSecret string) func(http.Handler) http.Handler { "auth_type", authType, "subject", userClaims["sub"]) ctx := contextWithUserClaims(r.Context(), userClaims) - next.ServeHTTP(w, r.WithContext(ctx)) + next.ServeHTTP(w, requestWithoutAuthorization(r, ctx)) return } log.DebugContext(r.Context(), "user token validation failed for registry request", "error", err) @@ -473,6 +473,12 @@ func JwtAuth(jwtSecret string) func(http.Handler) http.Handler { } } +func requestWithoutAuthorization(r *http.Request, ctx context.Context) *http.Request { + req := r.Clone(ctx) + req.Header.Del("Authorization") + return req +} + // extractPermissions reads the "permissions" claim from a JWT MapClaims // and stores the parsed scopes in the context. If the claim is absent, // the context is returned unmodified (meaning full access). If the claim diff --git a/lib/middleware/oapi_auth_test.go b/lib/middleware/oapi_auth_test.go index 588ac8f8..6d260858 100644 --- a/lib/middleware/oapi_auth_test.go +++ b/lib/middleware/oapi_auth_test.go @@ -238,6 +238,7 @@ func TestJwtAuth_TokenEndpointBypass(t *testing.T) { func TestJwtAuth_RegistryPathAcceptsUserTokens(t *testing.T) { nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Empty(t, r.Header.Get("Authorization")) w.Header().Set("X-User-ID", GetUserIDFromContext(r.Context())) if scopes.HasFullAccess(r.Context()) { w.Header().Set("X-Access", "full")