diff --git a/.github/workflows/hermes-image.yaml b/.github/workflows/hermes-image.yaml index 5032ad783..908d90fd8 100644 --- a/.github/workflows/hermes-image.yaml +++ b/.github/workflows/hermes-image.yaml @@ -11,15 +11,46 @@ on: description: Hermes Agent git ref required: false default: 2ffa1c97c09317c1d066aa5708b8ad961a4ca589 + hermes_lcm_ref: + description: Hermes LCM git ref + required: false + default: 34043036d12f69d0089c4630eb9acfb2d9424b4c tlon_apps_ref: description: tlon-apps git ref required: false - default: b9180da6491d29933a98f6e4f1b1458ce61ca576 + default: 33112008b1f3e83816dee61020dc5d4c57770c15 push: description: Push image to Docker Hub required: false type: boolean default: true + release_channel: + description: Version-server channel to update + required: false + type: choice + options: + - edge + - canary + - latest + default: edge + to_canary: + description: Also update canary when release_channel is edge + required: false + type: boolean + default: false + version_server: + description: Version server to update + required: false + type: choice + options: + - staging.version.groundseg.app + - version.groundseg.app + default: staging.version.groundseg.app + update_version_server: + description: Update selected version server after pushing + required: false + type: boolean + default: true push: branches: - main @@ -37,9 +68,11 @@ permissions: env: DEFAULT_IMAGE: nativeplanet/hermes-tlon - HERMES_IMAGE_TAG: 0.14.0-0.13.0 + HERMES_IMAGE_TAG: 0.14.0-0.14.0 HERMES_AGENT_REF: 2ffa1c97c09317c1d066aa5708b8ad961a4ca589 - TLON_APPS_REF: b9180da6491d29933a98f6e4f1b1458ce61ca576 + HERMES_LCM_REF: 34043036d12f69d0089c4630eb9acfb2d9424b4c + TLON_APPS_REF: 33112008b1f3e83816dee61020dc5d4c57770c15 + VERSION_AUTH: ${{ secrets.VERSION_AUTH }} jobs: build: @@ -53,22 +86,40 @@ jobs: set -euo pipefail image="${{ github.event.inputs.image }}" hermes_ref="${{ github.event.inputs.hermes_agent_ref }}" + lcm_ref="${{ github.event.inputs.hermes_lcm_ref }}" tlon_ref="${{ github.event.inputs.tlon_apps_ref }}" + release_channel="${{ github.event.inputs.release_channel }}" + to_canary="${{ github.event.inputs.to_canary }}" + version_server="${{ github.event.inputs.version_server }}" + update_version_server="${{ github.event.inputs.update_version_server }}" push_image="true" if [ -z "$image" ]; then image="$DEFAULT_IMAGE"; fi if [ -z "$hermes_ref" ]; then hermes_ref="$HERMES_AGENT_REF"; fi + if [ -z "$lcm_ref" ]; then lcm_ref="$HERMES_LCM_REF"; fi if [ -z "$tlon_ref" ]; then tlon_ref="$TLON_APPS_REF"; fi + if [ -z "$release_channel" ]; then release_channel="edge"; fi + if [ -z "$to_canary" ]; then to_canary="false"; fi + if [ -z "$version_server" ]; then version_server="staging.version.groundseg.app"; fi + if [ -z "$update_version_server" ]; then update_version_server="false"; fi if [ "${{ github.event_name }}" = "pull_request" ]; then push_image="false" elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ github.event.inputs.push }}" != "true" ]; then push_image="false" fi + if [ "${{ github.event_name }}" != "workflow_dispatch" ] || [ "$push_image" != "true" ]; then + update_version_server="false" + fi echo "image=$image" >> "$GITHUB_OUTPUT" echo "hermes-ref=$hermes_ref" >> "$GITHUB_OUTPUT" + echo "lcm-ref=$lcm_ref" >> "$GITHUB_OUTPUT" echo "tlon-ref=$tlon_ref" >> "$GITHUB_OUTPUT" echo "push=$push_image" >> "$GITHUB_OUTPUT" + echo "release-channel=$release_channel" >> "$GITHUB_OUTPUT" + echo "to-canary=$to_canary" >> "$GITHUB_OUTPUT" + echo "version-server=$version_server" >> "$GITHUB_OUTPUT" + echo "update-version-server=$update_version_server" >> "$GITHUB_OUTPUT" - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -80,8 +131,8 @@ jobs: if: steps.args.outputs.push == 'true' uses: docker/login-action@v3 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + username: nativeplanet + password: ${{ secrets.DOCKER_PAT }} - name: Build Hermes image uses: docker/build-push-action@v6 @@ -92,9 +143,90 @@ jobs: push: ${{ steps.args.outputs.push == 'true' }} build-args: | HERMES_AGENT_REF=${{ steps.args.outputs.hermes-ref }} + HERMES_LCM_REF=${{ steps.args.outputs.lcm-ref }} TLON_APPS_REF=${{ steps.args.outputs.tlon-ref }} tags: | ${{ steps.args.outputs.image }}:${{ env.HERMES_IMAGE_TAG }} ${{ steps.args.outputs.image }}:${{ env.HERMES_IMAGE_TAG }}-${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max + + - name: Install version update tools + if: steps.args.outputs.update-version-server == 'true' + run: | + sudo apt-get update + sudo apt-get install -y jq + + - name: Update version server + if: steps.args.outputs.update-version-server == 'true' + run: | + set -euo pipefail + + if [ -z "${VERSION_AUTH:-}" ]; then + echo "VERSION_AUTH secret is required to update the version server" + exit 1 + fi + + image="${{ steps.args.outputs.image }}" + tag="${{ env.HERMES_IMAGE_TAG }}-${{ github.sha }}" + image_ref="${image}:${tag}" + version_server="${{ steps.args.outputs.version-server }}" + + repo="$image" + case "$repo" in + registry.hub.docker.com/*) + ;; + docker.io/*) + repo="registry.hub.docker.com/${repo#docker.io/}" + ;; + */*) + repo="registry.hub.docker.com/${repo}" + ;; + *) + echo "Unsupported image name for version server repo: $repo" + exit 1 + ;; + esac + + manifest="$(docker buildx imagetools inspect --raw "$image_ref")" + amd64_sha="$(echo "$manifest" | jq -r '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64") | .digest' | sed 's/^sha256://')" + arm64_sha="$(echo "$manifest" | jq -r '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "arm64") | .digest' | sed 's/^sha256://')" + + if [ -z "$amd64_sha" ] || [ "$amd64_sha" = "null" ]; then + echo "Unable to resolve amd64 image digest for $image_ref" + exit 1 + fi + if [ -z "$arm64_sha" ] || [ "$arm64_sha" = "null" ]; then + echo "Unable to resolve arm64 image digest for $image_ref" + exit 1 + fi + + update_channel() { + channel="$1" + echo "Updating ${version_server} groundseg.${channel}.hermes -> ${repo}:${tag}" + + curl -f -X PUT \ + -H "X-Api-Key: ${VERSION_AUTH}" \ + -H 'Content-Type: application/json' \ + "https://${version_server}/modify/groundseg/${channel}/hermes/repo/payload" \ + -d "{\"value\":\"${repo}\"}" + + curl -f -X PUT \ + -H "X-Api-Key: ${VERSION_AUTH}" \ + "https://${version_server}/modify/groundseg/${channel}/hermes/tag/${tag}" + + curl -f -X PUT \ + -H "X-Api-Key: ${VERSION_AUTH}" \ + "https://${version_server}/modify/groundseg/${channel}/hermes/amd64_sha256/${amd64_sha}" + + curl -f -X PUT \ + -H "X-Api-Key: ${VERSION_AUTH}" \ + "https://${version_server}/modify/groundseg/${channel}/hermes/arm64_sha256/${arm64_sha}" + } + + release_channel="${{ steps.args.outputs.release-channel }}" + update_channel "$release_channel" + + if [ "$release_channel" = "edge" ] && [ "${{ steps.args.outputs.to-canary }}" = "true" ]; then + update_channel "canary" + fi diff --git a/.gitignore b/.gitignore index 78c1e5ff3..263777f7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ goseg/web/* +!goseg/web/hermes.png diff --git a/Jenkinsfile b/Jenkinsfile deleted file mode 100644 index 6269891fa..000000000 --- a/Jenkinsfile +++ /dev/null @@ -1,428 +0,0 @@ -pipeline { - agent any - parameters { - gitParameter( - name: 'RELEASE_TAG', - type: 'PT_BRANCH_TAG', - defaultValue: 'master') - choice( - choices: ['nobuild', 'edge', 'canary', 'latest'], - description: 'Publish to release channel', - name: 'CHANNEL' - ) - booleanParam( - name: 'TO_CANARY', - defaultValue: false, - description: 'Also push build to canary channel (if edge)' - ) - choice( - choices: ['build','promote'], - description: 'Build a release candidate tag for edge or promote an existing RC to latest (only works with `v2.X.X-rcX` tags)', - name: 'PROMOTE' - ) - choice( - choices: ['staging.version.groundseg.app' , 'version.groundseg.app'], - description: 'Choose version server', - name: 'VERSION_SERVER' - ) - } - environment { - /* choose release channel based on params */ - PROMOTE = "${params.PROMOTE}" - channel = "${params.CHANNEL}" - /* version server auth header */ - versionauth = credentials('VersionAuth') - npGhToken = credentials('NPJenkinsGH') - /* release tag to be built*/ - tag = "${params.RELEASE_TAG}" - /* staging or production version server */ - version_server = "${params.VERSION_SERVER}" - to_canary = "${params.TO_CANARY}" - } - stages { - stage('determine channel') { - steps { - script { - def channelValue = sh( - script: """#!/bin/bash -x - echo "${params.CHANNEL}" - """, - returnStdout: true - ).trim() - echo "Channel: ${channelValue}" - def binTagValue = '' - if (channelValue == "latest") { - binTagValue = env.tag.tokenize('-')[0] - } else { - binTagValue = env.tag - } - echo "BinTag: ${binTagValue}" - env.channel = channelValue - env.binTag = binTagValue - } - } - } - stage('checkout') { - steps { - checkout([$class: 'GitSCM', - branches: [[name: "${params.RELEASE_TAG}"]], - doGenerateSubmoduleConfigurations: false, - extensions: [], - gitTool: 'Default', - submoduleCfg: [], - userRemoteConfigs: [[credentialsId: 'Github token', url: 'https://github.com/Native-Planet/GroundSeg.git']] - ]) - } - } - stage('build') { - steps { - /* build binaries and move to web dir */ - script { - if(( "${params.CHANNEL}" != "nobuild" ) && ( "${params.CHANNEL}" != "latest" )) { - /* Goseg */ - sh '''#!/bin/bash -x - git checkout ${tag} - cd ./ui - DOCKER_BUILDKIT=0 docker build -t web-builder -f builder.Dockerfile . - container_id=$(docker create web-builder) - docker cp $container_id:/webui/build ./web - rm -rf ../goseg/web - mv web ../goseg/ - ''' - /* Gallseg */ - sh '''#!/bin/bash -x - cd ./ui - DOCKER_BUILDKIT=0 docker build -t web-builder -f gallseg.Dockerfile . - container_id=$(docker create web-builder) - git clone https://github.com/Native-Planet/globber - cd globber - docker cp $container_id:/webui/build ./web - ./glob.sh web - hash=$(ls -1 -c . | head -1 | sed "s/glob-\\([a-z0-9\\.]*\\).glob/\\1/") - globurl="https://files.native.computer/glob/gallseg-${tag}-${hash}.glob" - echo "hash=${hash}" > /opt/groundseg/version/glob/globhash.env - echo ${globurl} > /opt/groundseg/version/glob/globurl.txt - mv ./*.glob /opt/groundseg/version/glob/gallseg-${tag}-${hash}.glob - cd .. - rm -rf globber - cd .. - docketinfo=" glob-http+['${globurl}' ${hash}]" - sed -i "/glob-http/c\${docketinfo}" gallseg/desk.docket-0 - echo "~lablet-nallux-dozryl" > gallseg/desk.ship - rm -r /opt/groundseg/release-ships/edge/groundseg - cp -r gallseg /opt/groundseg/release-ships/edge/groundseg - /opt/groundseg/release-ships/click/click -k -i /opt/groundseg/release-ships/commit.hoon /opt/groundseg/release-ships/edge - ''' - sh """#!/bin/bash -x - cd ./goseg - env GOOS=linux CGO_ENABLED=0 GOARCH=amd64 go build -o /opt/groundseg/version/bin/groundseg_amd64_${env.binTag}_${env.channel} - env GOOS=linux CGO_ENABLED=0 GOARCH=arm64 go build -o /opt/groundseg/version/bin/groundseg_arm64_${env.binTag}_${env.channel} - """ - } - /* production releases get promoted from edge */ - if( "${params.CHANNEL}" == "latest" ) { - sh '''#!/bin/bash -x - tagRegex='^v[0-9]+\\.[0-9]+\\.[0-9]+-rc[0-9]+$' - if [[ ${tag} =~ $tagRegex ]]; then - echo "Valid pre-production release tag: ${tag}" - else - echo "Invalid tag for production release promotion: ${tag} -- should match format 'v2.1.52-rc2' etc" - exit 1 - fi - git checkout ${tag} - git checkout -b ${binTag}-release - git config --global user.email "mgmt@nativeplanet.io" - git config --global user.name "Native Planet CICD" - git config --global credential.helper store && echo "https://${npGhToken}:x-oauth-basic@github.com" > ~/.git-credentials - sed -i "4s/.*/export const version = writable(\\"${binTag}\\")/" ./ui/src/lib/stores/display.js - sed -i "11s/.*/TAG=${binTag}/" ./release/groundseg_install.sh - version_defaults="./goseg/defaults/version.go" - json_blob=$(curl -s https://version.groundseg.app) - formatted_json_blob=$(echo "$json_blob" | jq '.') - start_line=$(grep -n 'DefaultVersionText =' "$version_defaults" | cut -d ':' -f1) - end_line=$(grep -n 'VersionInfo' "$version_defaults" | cut -d ':' -f1) - temp_file=$(mktemp) - head -n $((start_line-1)) "$version_defaults" > "$temp_file" - echo " DefaultVersionText = \\`" >> "$temp_file" - echo "$formatted_json_blob" >> "$temp_file" - echo "\\`" >> "$temp_file" - tail -n +$end_line -q "$version_defaults" >> "$temp_file" - mv "$temp_file" "$version_defaults" - cd ./ui - DOCKER_BUILDKIT=0 docker build -t web-builder -f builder.Dockerfile . - container_id=$(docker create web-builder) - docker cp $container_id:/webui/build ./web - rm -rf ../goseg/web - mv web ../goseg/ - - DOCKER_BUILDKIT=0 docker build -t web-builder -f gallseg.Dockerfile . - container_id=$(docker create web-builder) - git clone https://github.com/Native-Planet/globber - cd globber - docker cp $container_id:/webui/build ./web - ./glob.sh web - - hash=$(ls -1 -c . | head -1 | sed "s/glob-\\([a-z0-9\\.]*\\).glob/\\1/") - globurl="https://files.native.computer/glob/gallseg-${tag}-${hash}.glob" - echo "hash=${hash}" > /opt/groundseg/version/glob/globhash.env - echo ${globurl} > /opt/groundseg/version/glob/globurl.txt - - mv ./*.glob /opt/groundseg/version/glob/gallseg-${tag}-${hash}.glob - cd .. - rm -rf globber - cd .. - docketinfo=" glob-http+['${globurl}' ${hash}]" - sed -i "/glob-http/c\${docketinfo}" gallseg/desk.docket-0 - echo "~nattyv" > gallseg/desk.ship - - rm -r /opt/groundseg/release-ships/latest/groundseg - cp -r gallseg /opt/groundseg/release-ships/latest/groundseg - /opt/groundseg/release-ships/click/click -k -i /opt/groundseg/release-ships/commit.hoon /opt/groundseg/release-ships/latest - - cd goseg - go fmt ./... - go mod tidy - git commit -am "Promoting ${binTag} for release" - git tag ${binTag} - git push --set-upstream origin ${binTag} - git push --tags - ''' - sh '''#!/bin/bash -x - cd goseg - env GOOS=linux CGO_ENABLED=0 GOARCH=amd64 go build -o /opt/groundseg/version/bin/groundseg_amd64_${binTag}_latest - env GOOS=linux CGO_ENABLED=0 GOARCH=arm64 go build -o /opt/groundseg/version/bin/groundseg_arm64_${binTag}_latest - ''' - } - } - } - } - stage('move binaries') { - steps { - script { - /* copy to r2 */ - if( "${params.CHANNEL}" != "nobuild" ) { - sh 'echo "debug: post-build actions"' - sh """#!/bin/bash -x - rclone -vvv --config /var/jenkins_home/rclone.conf copy /opt/groundseg/version/bin/groundseg_arm64_${env.binTag}_${env.channel} r2:groundseg/bin - rclone -vvv --config /var/jenkins_home/rclone.conf copy /opt/groundseg/version/bin/groundseg_amd64_${env.binTag}_${env.channel} r2:groundseg/bin - """ - sh '''#!/bin/bash -x - source /opt/groundseg/version/glob/globhash.env - rclone -vvv --config /var/jenkins_home/rclone.conf copy /opt/groundseg/version/glob/gallseg-${tag}-${hash}.glob r2:groundseg/glob - ''' - /* - */ - } - } - } - } - stage('version update') { - environment { - channel = "${params.CHANNEL}" - /* update versions and hashes on public version server */ - def armsha = sh( - script: """#!/bin/bash -x - val=`sha256sum /opt/groundseg/version/bin/groundseg_arm64_${env.binTag}_${env.channel} | awk '{print \$1}'` - echo \${val} - """, - returnStdout: true - ).trim() - def amdsha = sh( - script: """#!/bin/bash -x - val=`sha256sum /opt/groundseg/version/bin/groundseg_amd64_${env.binTag}_${env.channel} |awk '{print \$1}'` - echo \${val} - """, - returnStdout: true - ).trim() - major = sh( - script: '''#!/bin/bash -x - ver=${tag} - if [[ "${tag}" == *"-"* ]]; then - ver=`echo ${tag}|awk -F '-' '{print \$1}'` - fi - major=`echo ${ver}|awk -F '.' '{print \$1}'|sed 's/v//g'` - echo ${major} - ''', - returnStdout: true - ).trim() - minor = sh( - script: '''#!/bin/bash -x - ver=${tag} - if [[ "${tag}" == *"-"* ]]; then - ver=`echo ${tag}|awk -F '-' '{print \$1}'` - fi - minor=`echo ${ver}|awk -F '.' '{print \$2}'|sed 's/v//g'` - echo ${minor} - ''', - returnStdout: true - ).trim() - patch = sh( - script: '''#!/bin/bash -x - ver=${tag} - if [[ "${tag}" == *"-"* ]]; then - ver=`echo ${tag}|awk -F '-' '{print \$1}'` - fi - patch=`echo ${ver}|awk -F '.' '{print \$3}'|sed 's/v//g'` - echo ${patch} - ''', - returnStdout: true - ).trim() - armbin = "https://files.native.computer/bin/groundseg_arm64_${env.binTag}_${channel}" - amdbin = "https://files.native.computer/bin/groundseg_amd64_${env.binTag}_${channel}" - } - steps { - script { - def to_canary = "${params.TO_CANARY}".toLowerCase() - if( "${params.CHANNEL}" == "latest" ) { - sh '''#!/bin/bash -x - cp ./release/standard_install.sh /opt/groundseg/get/install.sh - cp ./release/groundseg_install.sh /opt/groundseg/get/only.sh - webui_amd64_hash=`curl https://${VERSION_SERVER} | jq -r '.[].edge.webui.amd64_sha256'` - webui_arm64_hash=`curl https://${VERSION_SERVER} | jq -r '.[].edge.webui.arm64_sha256'` - curl -X PUT -H "X-Api-Key: ${versionauth}" -H 'Content-Type: application/json' \ - https://${VERSION_SERVER}/modify/groundseg/latest/groundseg/amd64_url/payload \ - -d "{\\"value\\":\\"${amdbin}\\"}" - curl -X PUT -H "X-Api-Key: ${versionauth}" -H 'Content-Type: application/json' \ - https://${VERSION_SERVER}/modify/groundseg/latest/groundseg/arm64_url/payload \ - -d "{\\"value\\":\\"${armbin}\\"}" - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/latest/groundseg/amd64_sha256/${amdsha} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/latest/groundseg/arm64_sha256/${armsha} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/latest/groundseg/major/${major} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/latest/groundseg/minor/${minor} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/latest/groundseg/patch/${patch} - curl -X PUT -H "X-Api-Key: ${versionauth}" -H 'Content-Type: application/json' \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/amd64_url/payload \ - -d "{\\"value\\":\\"${amdbin}\\"}" - curl -X PUT -H "X-Api-Key: ${versionauth}" -H 'Content-Type: application/json' \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/arm64_url/payload \ - -d "{\\"value\\":\\"${armbin}\\"}" - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/amd64_sha256/${amdsha} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/arm64_sha256/${armsha} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/major/${major} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/minor/${minor} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/patch/${patch} - ''' - } - if( "${params.CHANNEL}" == "edge" ) { - sh '''#!/bin/bash -x - curl -X PUT -H "X-Api-Key: ${versionauth}" -H 'Content-Type: application/json' \ - https://${VERSION_SERVER}/modify/groundseg/edge/groundseg/amd64_url/payload \ - -d "{\\"value\\":\\"${amdbin}\\"}" - curl -X PUT -H "X-Api-Key: ${versionauth}" -H 'Content-Type: application/json' \ - https://${VERSION_SERVER}/modify/groundseg/edge/groundseg/arm64_url/payload \ - -d "{\\"value\\":\\"${armbin}\\"}" - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/edge/groundseg/amd64_sha256/${amdsha} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/edge/groundseg/arm64_sha256/${armsha} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/edge/groundseg/major/${major} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/edge/groundseg/minor/${minor} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/edge/groundseg/patch/${patch} - ''' - } - if( "${params.TO_CANARY}" == "true" ) { - sh '''#!/bin/bash -x - curl -X PUT -H "X-Api-Key: ${versionauth}" -H 'Content-Type: application/json' \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/amd64_url/payload \ - -d "{\\"value\\":\\"${amdbin}\\"}" - curl -X PUT -H "X-Api-Key: ${versionauth}" -H 'Content-Type: application/json' \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/arm64_url/payload \ - -d "{\\"value\\":\\"${armbin}\\"}" - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/amd64_sha256/${amdsha} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/arm64_sha256/${armsha} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/major/${major} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/minor/${minor} - curl -X PUT -H "X-Api-Key: ${versionauth}" \ - https://${VERSION_SERVER}/modify/groundseg/canary/groundseg/patch/${patch} - ''' - } - } - } - } - stage('SonarQube') { - environment { - scannerHome = "${tool 'SonarQubeScanner'}" - } - steps { - script { - if( "${params.CHANNEL}" == "edge" ) { - withSonarQubeEnv('SonarQube') { - sh "${scannerHome}/bin/sonar-scanner -Dsonar.projectKey=Native-Planet_GroundSeg_AYZoKNgHuu12TOn3FQ6N -Dsonar.sources=./goseg" - } - } - } - } - } - stage('merge to master') { - steps { - /* merge tag changes into master if deploying to master */ - script { - if(( "${params.CHANNEL}" == "latest" ) && ( "${params.PROMOTE}" == "promote" )) { - withCredentials([gitUsernamePassword(credentialsId: 'Github token', gitToolName: 'Default')]) { - sh ( - script: '''#!/bin/bash -x - git remote update - git fetch - git checkout --track origin/master - git merge ${binTag} -m "Merged ${tag}" - git config --global --add --bool push.autoSetupRemote true - git push - ''' - ) - } - } - } - } - } - stage('github release') { - steps { - script { - if(( "${params.CHANNEL}" == "latest" ) && ( "${params.VERSION_SERVER}" != "staging.version.groundseg.app")) { - sh ( - script: '''#!/bin/bash -x - MESSAGE="Release ${binTag}" - VERSION=$(echo "${binTag}"|sed "s/v//g") - API_JSON="{\\"tag_name\\": \\"${binTag}\\",\\"target_commitish\\": \\"master\\",\\"name\\": \\"${binTag}\\",\\"body\\": \\"${MESSAGE}\\",\\"draft\\": false,\\"prerelease\\": false}" - API_RESPONSE_STATUS=$(curl -H "Authorization: token ${npGhToken}" --data "$API_JSON" -s -i "https://api.github.com/repos/Native-Planet/GroundSeg/releases") - echo "Release: ${API_RESPONSE_STATUS}" - ''' - ) - } - } - } - } - } - post { - always { - cleanWs(cleanWhenNotBuilt: true, - deleteDirs: true, - disableDeferredWipeout: false, - notFailBuild: true, - patterns: [[pattern: '.gitignore', type: 'INCLUDE'], - [pattern: '.propsfile', type: 'EXCLUDE']]) - } - success { - script { - glob_url = readFile('/opt/groundseg/version/glob/globurl.txt').trim() - currentBuild.description = "Glob URL: ${glob_url}" - } - } - } -} diff --git a/containers/hermes/Dockerfile b/containers/hermes/Dockerfile new file mode 100644 index 000000000..0cbaca989 --- /dev/null +++ b/containers/hermes/Dockerfile @@ -0,0 +1,125 @@ +ARG NODE_VERSION=22.22.2 +FROM node:${NODE_VERSION}-bookworm-slim + +ARG HERMES_AGENT_REPO=https://github.com/NousResearch/hermes-agent.git +ARG HERMES_AGENT_REF=2ffa1c97c09317c1d066aa5708b8ad961a4ca589 +ARG HERMES_LCM_REPO=https://github.com/stephenschoettler/hermes-lcm.git +ARG HERMES_LCM_REF=34043036d12f69d0089c4630eb9acfb2d9424b4c +ARG TLON_APPS_REPO=https://github.com/tloncorp/tlon-apps.git +ARG TLON_APPS_REF=33112008b1f3e83816dee61020dc5d4c57770c15 +ARG PNPM_VERSION=9.15.9 + +LABEL org.opencontainers.image.title="NativePlanet Hermes Tlon" +LABEL org.opencontainers.image.description="Hermes Agent with the Tlon platform adapter and tlon CLI" +LABEL org.opencontainers.image.source="https://github.com/Native-Planet/GroundSeg" +LABEL org.nativeplanet.hermes.agent-ref="${HERMES_AGENT_REF}" +LABEL org.nativeplanet.hermes.lcm-ref="${HERMES_LCM_REF}" +LABEL org.nativeplanet.hermes.tlon-apps-ref="${TLON_APPS_REF}" + +ENV HERMES_AGENT_DIR=/opt/hermes-agent +ENV HERMES_VENV=/opt/hermes-venv +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright +ENV TLON_APPS_DIR=/opt/tlon-apps +ENV TLON_ADAPTER_DIR=/opt/tlon-apps/packages/hermes-tlon-adapter +ENV TLON_SKILL_DIR=/opt/tlon-apps/packages/tlon-skill +ENV TLON_CLI=/usr/local/bin/tlon +ENV TLON_SKILL_PATH=/opt/tlon-apps/packages/tlon-skill/SKILL.md +ENV HERMES_HOME=/opt/data +ENV HERMES_WORKSPACE=/workspace +ENV HERMES_CONTAINER_HOME=/workspace/home +ENV HERMES_WEB_DIST=/opt/hermes-agent/hermes_cli/web_dist +ENV HERMES_DASHBOARD=1 +ENV HERMES_DASHBOARD_HOST=0.0.0.0 +ENV HERMES_DASHBOARD_PORT=9119 +ENV HERMES_MODEL_PROVIDER=openrouter +ENV HERMES_MODEL=deepseek/deepseek-v4-flash +ENV TLON_TELEMETRY=false +ENV TERMINAL_ENV=local +ENV TERMINAL_CWD=/workspace +ENV TERMINAL_LOCAL_PERSISTENT=true +ENV TERMINAL_TIMEOUT=180 +ENV TERMINAL_MAX_FOREGROUND_TIMEOUT=900 +ENV PATH="/opt/hermes-venv/bin:${PATH}" + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + build-essential \ + ca-certificates \ + curl \ + ffmpeg \ + git \ + libffi-dev \ + openssh-client \ + procps \ + python3 \ + python3-dev \ + python3-pip \ + python3-venv \ + ripgrep \ + tini \ + tmux \ + unzip \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g "pnpm@${PNPM_VERSION}" + +RUN git clone --filter=blob:none "${HERMES_AGENT_REPO}" "${HERMES_AGENT_DIR}" \ + && cd "${HERMES_AGENT_DIR}" \ + && git checkout "${HERMES_AGENT_REF}" + +COPY patches /tmp/hermes-patches +RUN cd "${HERMES_AGENT_DIR}" \ + && for patch in /tmp/hermes-patches/*.patch; do git apply "$patch"; done \ + && rm -rf /tmp/hermes-patches + +RUN cd "${HERMES_AGENT_DIR}" \ + && npm install --prefer-offline --no-audit \ + && npx playwright install --with-deps chromium --only-shell \ + && cd web \ + && npm install --prefer-offline --no-audit \ + && npm run build \ + && cd ../ui-tui \ + && npm install --prefer-offline --no-audit \ + && npm run build \ + && npm cache clean --force + +RUN python3 -m venv "${HERMES_VENV}" \ + && "${HERMES_VENV}/bin/python" -m pip install --upgrade pip setuptools wheel \ + && "${HERMES_VENV}/bin/python" -m pip install -e "${HERMES_AGENT_DIR}[cron,cli,pty,mcp,acp,web]" \ + && "${HERMES_VENV}/bin/python" -m pip install "aiohttp>=3.9" + +RUN git clone --filter=blob:none "${HERMES_LCM_REPO}" /tmp/hermes-lcm \ + && cd /tmp/hermes-lcm \ + && git checkout "${HERMES_LCM_REF}" \ + && mkdir -p "${HERMES_AGENT_DIR}/plugins/context_engine/lcm" \ + && cp -a . "${HERMES_AGENT_DIR}/plugins/context_engine/lcm/" \ + && ln -sfn "${HERMES_AGENT_DIR}/plugins/context_engine/lcm" "${HERMES_AGENT_DIR}/hermes_lcm" \ + && rm -rf "${HERMES_AGENT_DIR}/plugins/context_engine/lcm/.git" /tmp/hermes-lcm + +RUN git clone --filter=blob:none "${TLON_APPS_REPO}" "${TLON_APPS_DIR}" \ + && cd "${TLON_APPS_DIR}" \ + && git checkout "${TLON_APPS_REF}" + +RUN cd "${TLON_APPS_DIR}" \ + && pnpm install --filter @tloncorp/tlon-skill... --frozen-lockfile --ignore-scripts \ + && pnpm --filter @tloncorp/api build \ + && cd "${TLON_SKILL_DIR}" \ + && version="$(node -p "require('./package.json').version")" \ + && pnpm exec esbuild scripts/main.ts --bundle --platform=node --format=cjs --target=node22 --outfile=dist/tlon.cjs --define:__VERSION__=\"${version}\" \ + && pnpm store prune + +RUN mkdir -p "${HERMES_HOME}" "${HERMES_WORKSPACE}" "${HERMES_CONTAINER_HOME}" \ + && ln -sf /usr/bin/python3 /usr/local/bin/python \ + && ln -sf "${HERMES_VENV}/bin/hermes" /usr/local/bin/hermes \ + && ln -sf "${HERMES_VENV}/bin/hermes-agent" /usr/local/bin/hermes-agent +VOLUME [ "/opt/data", "/workspace" ] +WORKDIR /workspace + +COPY tlon /usr/local/bin/tlon +COPY entrypoint.sh /usr/local/bin/hermes-tlon-entrypoint +RUN chmod +x /usr/local/bin/tlon /usr/local/bin/hermes-tlon-entrypoint \ + && "${TLON_CLI}" --help >/dev/null + +ENTRYPOINT ["/usr/bin/tini", "-g", "--", "/usr/local/bin/hermes-tlon-entrypoint"] +CMD ["gateway", "run", "--replace", "--accept-hooks"] diff --git a/containers/hermes/entrypoint.sh b/containers/hermes/entrypoint.sh new file mode 100644 index 000000000..74607483e --- /dev/null +++ b/containers/hermes/entrypoint.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +set -euo pipefail + +HERMES_HOME="${HERMES_HOME:-/opt/data}" +HERMES_WORKSPACE="${HERMES_WORKSPACE:-/workspace}" +HERMES_CONTAINER_HOME="${HERMES_CONTAINER_HOME:-$HERMES_WORKSPACE/home}" +HOME="$HERMES_CONTAINER_HOME" +HERMES_AGENT_DIR="${HERMES_AGENT_DIR:-/opt/hermes-agent}" +TLON_ADAPTER_DIR="${TLON_ADAPTER_DIR:-/opt/tlon-apps/packages/hermes-tlon-adapter}" +TLON_SKILL_DIR="${TLON_SKILL_DIR:-/opt/tlon-apps/packages/tlon-skill}" +TLON_CLI="${TLON_CLI:-/usr/local/bin/tlon}" +TERMINAL_ENV="${TERMINAL_ENV:-local}" +TERMINAL_CWD="${TERMINAL_CWD:-$HERMES_WORKSPACE}" +TERMINAL_LOCAL_PERSISTENT="${TERMINAL_LOCAL_PERSISTENT:-true}" +TERMINAL_TIMEOUT="${TERMINAL_TIMEOUT:-180}" +TERMINAL_MAX_FOREGROUND_TIMEOUT="${TERMINAL_MAX_FOREGROUND_TIMEOUT:-900}" +HERMES_TLON_TOOLSET="${HERMES_TLON_TOOLSET:-hermes-tlon}" + +export HERMES_HOME HERMES_WORKSPACE HERMES_CONTAINER_HOME HOME +export HERMES_AGENT_DIR TLON_ADAPTER_DIR TLON_SKILL_DIR TLON_CLI +export TERMINAL_ENV TERMINAL_CWD TERMINAL_LOCAL_PERSISTENT TERMINAL_TIMEOUT TERMINAL_MAX_FOREGROUND_TIMEOUT +export HERMES_TLON_TOOLSET + +if [ -z "${BRAVE_SEARCH_API_KEY:-}" ] && [ -n "${BRAVE_API_KEY:-}" ]; then + export BRAVE_SEARCH_API_KEY="$BRAVE_API_KEY" +fi + +if [ -z "${TLON_HOME_CHANNEL:-}" ] && [ -n "${TLON_OWNER_SHIP:-}" ]; then + export TLON_HOME_CHANNEL="$TLON_OWNER_SHIP" +fi + +if [ ! -f "$TLON_ADAPTER_DIR/plugin.yaml" ]; then + echo "ERROR: Tlon Hermes adapter is missing at $TLON_ADAPTER_DIR" >&2 + exit 1 +fi + +if [ ! -x "$TLON_CLI" ]; then + echo "ERROR: tlon CLI is missing or not executable at $TLON_CLI" >&2 + exit 1 +fi + +if ! "$TLON_CLI" --help >/dev/null 2>&1; then + echo "ERROR: tlon CLI failed its startup smoke check" >&2 + "$TLON_CLI" --help >/dev/null + exit 1 +fi + +mkdir -p "$HERMES_HOME/plugins/platforms" "$HERMES_HOME/logs" "$HERMES_HOME/memories" "$HERMES_WORKSPACE" "$HERMES_CONTAINER_HOME" +ln -sfn "$TLON_ADAPTER_DIR" "$HERMES_HOME/plugins/platforms/tlon" + +python3 - <<'PY' +import os +import re +from pathlib import Path + +import yaml + +home = Path(os.environ["HERMES_HOME"]) +workspace = Path(os.environ.get("HERMES_WORKSPACE") or "/workspace") +adapter_dir = Path(os.environ["TLON_ADAPTER_DIR"]) +prompts_dir = adapter_dir / "prompts" +prompts_root = prompts_dir.resolve() +include_re = re.compile(r"(?m)^\{\{include:([^}]+)\}\}\s*$") + + +def env_any(names, default): + for name in names: + value = (os.environ.get(name) or "").strip() + if value: + return value + return default + + +def env_int(name, default): + try: + return int(os.environ.get(name) or default) + except (TypeError, ValueError): + return default + + +values = { + "TLON_NODE_ID": env_any(["TLON_NODE_ID", "TLON_SHIP", "URBIT_SHIP"], "the configured bot node"), + "TLON_OWNER_SHIP": env_any(["TLON_OWNER_SHIP"], "the configured owner ship"), + "TLON_NODE_URL": env_any(["TLON_NODE_URL", "TLON_SHIP_URL", "TLON_URL", "URBIT_URL"], "the configured Tlon node URL"), +} + + +def checked_prompt_path(rel): + path = (prompts_dir / rel).resolve() + if path != prompts_root and prompts_root not in path.parents: + raise ValueError(f"Prompt include escapes prompts directory: {rel}") + return path + + +def render_prompt(rel, stack=()): + if rel in stack: + raise ValueError(f"Prompt include cycle: {' -> '.join((*stack, rel))}") + path = checked_prompt_path(rel) + text = path.read_text(encoding="utf-8") + + def include(match): + return render_prompt(match.group(1).strip(), (*stack, rel)).rstrip() + + text = include_re.sub(include, text) + for key, value in values.items(): + text = text.replace("{{" + key + "}}", value) + return text.strip() + "\n" + + +def upsert_managed_block(target, rel, *, replace_default_soul=False, memory_file=False): + rendered = render_prompt(rel).rstrip() + start = f"" + end = f"" + block = f"{start}\n{rendered}\n{end}\n" + target.parent.mkdir(parents=True, exist_ok=True) + current = target.read_text(encoding="utf-8") if target.exists() else "" + default_soul = "You are Hermes Agent, an intelligent AI assistant created by Nous Research." + + if start in current and end in current: + pattern = re.compile(re.escape(start) + r".*?" + re.escape(end) + r"\n?", re.S) + updated = pattern.sub(block, current) + elif replace_default_soul and current.strip().startswith(default_soul): + updated = block + elif current.strip(): + separator = "\n---\n" if memory_file else "\n\n" + updated = current.rstrip() + separator + block + else: + updated = block + target.write_text(updated, encoding="utf-8") + + +upsert_managed_block(home / "SOUL.md", "hermes/SOUL.md", replace_default_soul=True) +upsert_managed_block(home / ".hermes.md", "hermes/.hermes.md") +upsert_managed_block(home / "memories" / "USER.md", "hermes/USER.md", memory_file=True) + +config_path = home / "config.yaml" +config = yaml.safe_load(config_path.read_text()) if config_path.exists() else {} +if not isinstance(config, dict): + config = {} + +plugins = config.setdefault("plugins", {}) +enabled = plugins.setdefault("enabled", []) +if not isinstance(enabled, list): + enabled = [] + plugins["enabled"] = enabled +if "platforms/tlon" not in enabled: + enabled.append("platforms/tlon") + +gateway = config.setdefault("gateway", {}) +gateway_platforms = gateway.setdefault("platforms", {}) +gateway_tlon = gateway_platforms.setdefault("tlon", {}) +gateway_tlon["enabled"] = True + +platforms = config.setdefault("platforms", {}) +tlon = platforms.setdefault("tlon", {}) +tlon["enabled"] = True + +terminal = config.get("terminal") +if not isinstance(terminal, dict): + terminal = {} +terminal["backend"] = os.environ.get("TERMINAL_ENV") or "local" +terminal["cwd"] = os.environ.get("TERMINAL_CWD") or str(workspace) +terminal["timeout"] = env_int("TERMINAL_TIMEOUT", 180) +terminal["persistent_shell"] = str(os.environ.get("TERMINAL_LOCAL_PERSISTENT") or "true").lower() in {"true", "1", "yes"} +config["terminal"] = terminal + +toolsets_raw = ( + os.environ.get("HERMES_TLON_TOOLSETS") + or os.environ.get("HERMES_TLON_TOOLSET") + or "hermes-tlon" +) +toolsets_selected = [] +for item in re.split(r"[,:\s]+", toolsets_raw): + item = item.strip() + if item and item not in toolsets_selected: + toolsets_selected.append(item) +if toolsets_selected: + toolsets = config.get("toolsets") + if not isinstance(toolsets, list): + toolsets = [] + for toolset in toolsets_selected: + if toolset not in toolsets: + toolsets.append(toolset) + config["toolsets"] = toolsets + + platform_toolsets = config.get("platform_toolsets") + if not isinstance(platform_toolsets, dict): + platform_toolsets = {} + tlon_toolsets = platform_toolsets.get("tlon") + if not isinstance(tlon_toolsets, list) or not tlon_toolsets: + tlon_toolsets = list(toolsets_selected) + else: + current = {str(item).strip() for item in tlon_toolsets if str(item).strip()} + if current <= {"tlon", "hermes-tlon"}: + tlon_toolsets = list(toolsets_selected) + platform_toolsets["tlon"] = tlon_toolsets + config["platform_toolsets"] = platform_toolsets + +home_channel = ( + os.environ.get("TLON_HOME_CHANNEL") + or os.environ.get("TLON_OWNER_SHIP") + or os.environ.get("TLON_GATEWAY_STATUS_OWNER") + or "" +).strip() +if home_channel: + home_channel_config = { + "platform": "tlon", + "chat_id": home_channel, + "name": home_channel, + } + tlon["home_channel"] = home_channel_config + gateway_tlon["home_channel"] = home_channel_config + +provider = (os.environ.get("HERMES_MODEL_PROVIDER") or os.environ.get("HERMES_PROVIDER") or "").strip() +model = (os.environ.get("HERMES_MODEL") or os.environ.get("MODEL") or "").strip() +if provider or model: + model_config = config.get("model") + if not isinstance(model_config, dict): + model_config = {} + if provider: + model_config["provider"] = provider + if model: + model_config["default"] = model + config["model"] = model_config + +web_backend = (os.environ.get("HERMES_WEB_BACKEND") or "").strip() +web_search_backend = (os.environ.get("HERMES_WEB_SEARCH_BACKEND") or web_backend).strip() +if not web_search_backend: + if (os.environ.get("BRAVE_SEARCH_API_KEY") or "").strip(): + web_search_backend = "brave-free" + elif (os.environ.get("EXA_API_KEY") or "").strip(): + web_search_backend = "exa" + elif (os.environ.get("FIRECRAWL_API_KEY") or "").strip(): + web_search_backend = "firecrawl" + elif (os.environ.get("PARALLEL_API_KEY") or "").strip(): + web_search_backend = "parallel" + elif (os.environ.get("SEARXNG_URL") or "").strip(): + web_search_backend = "searxng" + elif (os.environ.get("TAVILY_API_KEY") or "").strip(): + web_search_backend = "tavily" + elif (os.environ.get("XAI_API_KEY") or "").strip(): + web_search_backend = "xai" + +web_extract_backend = (os.environ.get("HERMES_WEB_EXTRACT_BACKEND") or "").strip() +if not web_extract_backend: + if (os.environ.get("EXA_API_KEY") or "").strip(): + web_extract_backend = "exa" + elif (os.environ.get("FIRECRAWL_API_KEY") or "").strip(): + web_extract_backend = "firecrawl" + elif (os.environ.get("PARALLEL_API_KEY") or "").strip(): + web_extract_backend = "parallel" + elif (os.environ.get("TAVILY_API_KEY") or "").strip(): + web_extract_backend = "tavily" +if web_search_backend or web_extract_backend: + web_config = config.get("web") + if not isinstance(web_config, dict): + web_config = {} + if web_search_backend: + web_config["search_backend"] = web_search_backend + if web_extract_backend: + web_config["extract_backend"] = web_extract_backend + config["web"] = web_config + +config_path.write_text(yaml.safe_dump(config, sort_keys=False), encoding="utf-8") +PY + +if [ -d "$HERMES_AGENT_DIR/skills" ] && [ -f "$HERMES_AGENT_DIR/tools/skills_sync.py" ]; then + python3 "$HERMES_AGENT_DIR/tools/skills_sync.py" || true +fi + +case "${HERMES_DASHBOARD:-}" in + 1|true|TRUE|True|yes|YES|Yes) + dash_host="${HERMES_DASHBOARD_HOST:-0.0.0.0}" + dash_port="${HERMES_DASHBOARD_PORT:-9119}" + dash_args=(--host "$dash_host" --port "$dash_port" --no-open) + if [ "$dash_host" != "127.0.0.1" ] && [ "$dash_host" != "localhost" ]; then + dash_args+=(--insecure) + fi + ( + stdbuf -oL -eL hermes dashboard "${dash_args[@]}" 2>&1 \ + | sed -u 's/^/[dashboard] /' + ) & + ;; +esac + +if [ $# -gt 0 ] && command -v "$1" >/dev/null 2>&1; then + exec "$@" +fi + +exec hermes "$@" diff --git a/containers/hermes/patches/groundseg-hermes-dashboard-config.patch b/containers/hermes/patches/groundseg-hermes-dashboard-config.patch new file mode 100644 index 000000000..bdd701fc2 --- /dev/null +++ b/containers/hermes/patches/groundseg-hermes-dashboard-config.patch @@ -0,0 +1,89 @@ +--- a/agent/file_safety.py ++++ b/agent/file_safety.py +@@ -91,8 +91,15 @@ + home = os.path.realpath(os.path.expanduser("~")) + resolved = os.path.realpath(os.path.expanduser(str(path))) + +- if resolved in build_write_denied_paths(home): +- return True ++ allow_config_write = os.getenv("HERMES_ALLOW_CONFIG_WRITE", "").strip().lower() in {"1", "true", "yes", "on"} ++ denied_paths = build_write_denied_paths(home) ++ if resolved in denied_paths: ++ allowed_paths = set() ++ if allow_config_write: ++ allowed_paths.add(os.path.realpath(_hermes_home_path() / ".env")) ++ allowed_paths.add(os.path.realpath(_hermes_root_path() / ".env")) ++ if resolved not in allowed_paths: ++ return True + for prefix in build_write_denied_prefixes(home): + if resolved.startswith(prefix): + return True +@@ -116,6 +123,8 @@ + + for base_real in hermes_dirs: + for name in control_file_names: ++ if allow_config_write and name == "config.yaml": ++ continue + try: + if resolved == os.path.realpath(os.path.join(base_real, name)): + return True +--- a/hermes_cli/web_server.py ++++ b/hermes_cli/web_server.py +@@ -4319,12 +4319,18 @@ + + def _validate_plugin_name(name: str) -> str: + """Reject path-traversal attempts in plugin name URL parameters.""" +- if not name or "/" in name or "\\" in name or ".." in name: ++ name = (name or "").strip().strip("/") ++ parts = [part for part in name.split("/") if part] ++ if ( ++ not parts ++ or "\\" in name ++ or any(part in {".", ".."} for part in parts) ++ ): + raise HTTPException(status_code=400, detail="Invalid plugin name.") +- return name ++ return "/".join(parts) + + +-@app.post("/api/dashboard/agent-plugins/{name}/enable") ++@app.post("/api/dashboard/agent-plugins/{name:path}/enable") + async def post_agent_plugin_enable(request: Request, name: str): + _require_token(request) + name = _validate_plugin_name(name) +@@ -4336,7 +4342,7 @@ + return result + + +-@app.post("/api/dashboard/agent-plugins/{name}/disable") ++@app.post("/api/dashboard/agent-plugins/{name:path}/disable") + async def post_agent_plugin_disable(request: Request, name: str): + _require_token(request) + name = _validate_plugin_name(name) +@@ -4348,7 +4354,7 @@ + return result + + +-@app.post("/api/dashboard/agent-plugins/{name}/update") ++@app.post("/api/dashboard/agent-plugins/{name:path}/update") + async def post_agent_plugin_update(request: Request, name: str): + _require_token(request) + name = _validate_plugin_name(name) +@@ -4361,7 +4367,7 @@ + return result + + +-@app.delete("/api/dashboard/agent-plugins/{name}") ++@app.delete("/api/dashboard/agent-plugins/{name:path}") + async def delete_agent_plugin(request: Request, name: str): + _require_token(request) + name = _validate_plugin_name(name) +@@ -4399,7 +4405,7 @@ + hidden: bool + + +-@app.post("/api/dashboard/plugins/{name}/visibility") ++@app.post("/api/dashboard/plugins/{name:path}/visibility") + async def post_plugin_visibility(request: Request, name: str, body: _PluginVisibilityBody): + """Toggle a plugin's sidebar visibility (persists to config.yaml dashboard.hidden_plugins).""" + _require_token(request) diff --git a/containers/hermes/tlon b/containers/hermes/tlon new file mode 100644 index 000000000..c331a9a5b --- /dev/null +++ b/containers/hermes/tlon @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +set -eu + +bundle="${TLON_CLI_BUNDLE:-/opt/tlon-apps/packages/tlon-skill/dist/tlon.cjs}" + +if [ ! -f "$bundle" ]; then + cat >&2 < =(our.bowl src.bowl) ^- (quip card _this) - ?> ?=(%penpai-do mark) + ?> ?=(%groundseg-do mark) ?> =(our.bol src.bol) =+ !<(=do vase) ?- -.do diff --git a/goseg/broadcast/broadcast.go b/goseg/broadcast/broadcast.go index 2699e58cd..5c694716f 100644 --- a/goseg/broadcast/broadcast.go +++ b/goseg/broadcast/broadcast.go @@ -15,7 +15,6 @@ import ( "path" "path/filepath" "regexp" - "runtime" "slices" "strconv" "strings" @@ -142,6 +141,14 @@ func LoadStartramRegions() error { return nil } +func appendUniqueString(items []string, item string) []string { + item = strings.TrimSpace(item) + if item == "" || slices.Contains(items, item) { + return items + } + return append(items, item) +} + // this is for building the broadcast objects describing piers func ConstructPierInfo() (map[string]structs.Urbit, error) { // get a list of piers @@ -219,6 +226,19 @@ func ConstructPierInfo() (map[string]structs.Urbit, error) { zap.L().Debug("Defaulting to `nativeplanet.local`") hostName = "nativeplanet.local" } + vereTags, err := docker.GetVereImageTags() + if err != nil { + zap.L().Warn(fmt.Sprintf("Unable to fetch Vere image tags: %v", err)) + } + versionServerVereTag := "" + versionServerVereRepo := "" + if containerInfo, infoErr := docker.GetLatestContainerInfo("vere"); infoErr == nil { + versionServerVereTag = containerInfo["tag"] + versionServerVereRepo = containerInfo["repo"] + vereTags = appendUniqueString(vereTags, versionServerVereTag) + } else { + zap.L().Warn(fmt.Sprintf("Unable to read version-server Vere info: %v", infoErr)) + } // convert the running status into bools for pier, status := range pierStatus { // pull urbit info from json @@ -243,9 +263,11 @@ func ConstructPierInfo() (map[string]structs.Urbit, error) { bootStatus = false } setRemote := false - urbitURL := fmt.Sprintf("http://%s:%d", hostName, dockerConfig.HTTPPort) + urbitURL := docker.UrbitWebURL(hostName, dockerConfig) + if urbitURL == "" { + urbitURL = "#" + } if dockerConfig.Network == "wireguard" { - urbitURL = fmt.Sprintf("https://%s", dockerConfig.WgURL) setRemote = true } remoteReady := false @@ -285,16 +307,6 @@ func ConstructPierInfo() (map[string]structs.Urbit, error) { minioLinked := config.GetMinIOLinkedStatus(pier) - var penpaiCompanionInstalled bool - if strings.Contains(pierStatus[pier], "Up") { - deskStatus, err := click.GetDesk(pier, "penpai", false) - if err != nil { - penpaiCompanionInstalled = false - zap.L().Debug(fmt.Sprintf("Broadcast failed to get penpai desk info for %v: %v", pier, err)) - } - penpaiCompanionInstalled = deskStatus == "running" - } - var gallsegInstalled bool if strings.Contains(pierStatus[pier], "Up") { deskStatus, err := click.GetDesk(pier, "groundseg", false) @@ -343,6 +355,14 @@ func ConstructPierInfo() (map[string]structs.Urbit, error) { urbit.Info.MemUsage = dockerStats.MemoryUsage urbit.Info.ExtraArgs = dockerConfig.ExtraArgs urbit.Info.BootCommandBase = bootCommandBase + urbit.Info.UrbitVersion = dockerConfig.UrbitVersion + urbit.Info.UrbitRepo = dockerConfig.UrbitRepo + if urbit.Info.UrbitRepo == "" { + urbit.Info.UrbitRepo = versionServerVereRepo + } + urbit.Info.UrbitImageTagOverride = dockerConfig.UrbitImageTagOverride + urbit.Info.VereTags = appendUniqueString(append([]string{}, vereTags...), dockerConfig.UrbitImageTagOverride) + urbit.Info.VersionServerVereTag = versionServerVereTag urbit.Info.DevMode = dockerConfig.DevMode urbit.Info.Vere = dockerConfig.UrbitVersion urbit.Info.DetectBootStatus = bootStatus @@ -364,7 +384,6 @@ func ConstructPierInfo() (map[string]structs.Urbit, error) { urbit.Info.NextPack = strconv.FormatInt(GetScheduledPack(pier).Unix(), 10) urbit.Info.PackIntervalType = dockerConfig.MeldScheduleType urbit.Info.PackIntervalValue = dockerConfig.MeldFrequency - urbit.Info.PenpaiCompanion = penpaiCompanionInstalled urbit.Info.Gallseg = gallsegInstalled urbit.Info.StartramReminder = startramReminder urbit.Info.ChopOnUpgrade = chopOnUpgrade @@ -399,26 +418,13 @@ func ConstructPierInfo() (map[string]structs.Urbit, error) { func constructAppsInfo() structs.Apps { var apps structs.Apps - conf := config.Conf() - - // penpai - var modelTitles []string - // Iterate through penpais to extract modelTitle - for _, penpaiInfo := range conf.PenpaiModels { - modelTitles = append(modelTitles, penpaiInfo.ModelTitle) - } - apps.Penpai.Info.Models = modelTitles - apps.Penpai.Info.Allowed = conf.PenpaiAllow - apps.Penpai.Info.ActiveModel = conf.PenpaiActive - apps.Penpai.Info.Running = conf.PenpaiRunning - apps.Penpai.Info.MaxCores = runtime.NumCPU() - 1 - apps.Penpai.Info.ActiveCores = conf.PenpaiCores return apps } func constructProfileInfo() structs.Profile { // Build startram struct var startramInfo structs.Startram + var hermesInfo structs.Hermes // Information from config conf := config.Conf() startramInfo.Info.Registered = conf.WgRegistered @@ -469,9 +475,61 @@ func constructProfileInfo() structs.Profile { // Get Regions startramInfo.Info.Regions = broadcastState.Profile.Startram.Info.Regions + + if err := config.LoadHermesConfig(); err != nil { + zap.L().Warn(fmt.Sprintf("Unable to load Hermes profile config: %v", err)) + } + hermesConf := config.HermesConf() + hermesRunning := false + if hermesContainer, err := docker.FindContainer(docker.HermesContainerName); err == nil && hermesContainer != nil { + hermesRunning = hermesContainer.State == "running" + } + hermesURL := "#" + hostName := system.LocalUrl + if hostName == "" { + hostName = "nativeplanet.local" + } + if hermesConf.Port > 0 { + hermesURL = fmt.Sprintf("http://%s:%d", hostName, hermesConf.Port) + } + hermesInfo.Info.Enabled = hermesConf.Enabled + hermesInfo.Info.Running = hermesRunning + hermesInfo.Info.URL = hermesURL + hermesInfo.Info.Ship = docker.NormalizeHermesShip(hermesConf.Ship) + hermesInfo.Info.Owner = docker.NormalizeHermesShip(hermesConf.Owner) + hermesInfo.Info.Port = hermesConf.Port + hermesInfo.Info.Image = docker.HermesImageOrDefault(hermesConf.Image) + if versionServerImage, err := docker.HermesVersionServerImage(); err == nil { + hermesInfo.Info.VersionServerImage = versionServerImage + hermesInfo.Info.UpdateAvailable = docker.HermesUpdateAvailable(hermesConf.Image) + } else { + zap.L().Debug(fmt.Sprintf("Unable to read version-server Hermes info: %v", err)) + } + hermesInfo.Info.HermesVersion = docker.HermesVersionOrDefault(hermesConf.HermesVersion) + hermesInfo.Info.HermesAgentRef = docker.HermesAgentRefOrDefault(hermesConf.HermesAgentRef) + hermesInfo.Info.TlonAdapterVersion = docker.HermesTlonAdapterVersionOrDefault(hermesConf.TlonAdapterVersion) + hermesInfo.Info.TlonAdapterRef = docker.HermesTlonAdapterRefOrDefault(hermesConf.TlonAdapterRef) + hermesInfo.Info.ModelProvider = docker.HermesModelProviderOrDefault(hermesConf.ModelProvider) + hermesInfo.Info.Model = docker.HermesModelOrDefault(hermesConf.Model) + hermesInfo.Info.ProviderAPIKeySet = strings.TrimSpace(hermesConf.ProviderAPIKey) != "" + hermesInfo.Info.WebProvider = docker.HermesWebProviderOrEmpty(hermesConf.WebProvider) + hermesInfo.Info.WebAPIKeySet = strings.TrimSpace(hermesConf.WebAPIKey) != "" + hermesInfo.Info.WebURL = strings.TrimSpace(hermesConf.WebURL) + hermesInfo.Info.APIEnabled = hermesConf.APIEnabled + hermesInfo.Info.APIKeySet = strings.TrimSpace(hermesConf.APIKey) != "" + if installed, err := docker.ImageRefExists(hermesInfo.Info.Image); err == nil { + hermesInfo.Info.ImageInstalled = installed + } else { + zap.L().Warn(fmt.Sprintf("Unable to inspect Hermes image %s: %v", hermesInfo.Info.Image, err)) + } + for _, pier := range conf.Piers { + hermesInfo.Info.Ships = append(hermesInfo.Info.Ships, docker.NormalizeHermesShip(pier)) + } + // Build profile struct var profile structs.Profile profile.Startram = startramInfo + profile.Hermes = hermesInfo return profile } diff --git a/goseg/broadcast/loop.go b/goseg/broadcast/loop.go index 04adf6091..f8d9063eb 100644 --- a/goseg/broadcast/loop.go +++ b/goseg/broadcast/loop.go @@ -59,6 +59,7 @@ func BroadcastLoop() { func PreserveProfileTransitions(oldState structs.AuthBroadcast, newProfile structs.Profile) structs.Profile { newProfile.Startram.Transition = oldState.Profile.Startram.Transition + newProfile.Hermes.Transition = oldState.Profile.Hermes.Transition return newProfile } diff --git a/goseg/click/desk.go b/goseg/click/desk.go index 7125340eb..f9e534960 100644 --- a/goseg/click/desk.go +++ b/goseg/click/desk.go @@ -115,7 +115,7 @@ func getDesk(patp, desk string, bypass bool) (string, error) { vats, _, err := filterResponse("desk", response) if err != nil { storeDeskError(patp, desk) - return "", fmt.Errorf("Click penpai desk info failed to get exec: %v", err) + return "", fmt.Errorf("Click get desk %%%v failed to parse response: %v", desk, err) } storeDesk(patp, desk, vats) return vats, nil @@ -217,7 +217,7 @@ func fetchDeskFromMemory(patp, desk string) (string, error) { } func storeDeskError(patp, desk string) { - zap.L().Debug(fmt.Sprintf("Recording penpai desk info failure for %s", patp)) + zap.L().Debug(fmt.Sprintf("Recording %%%v desk info failure for %s", desk, patp)) desksMutex.Lock() defer desksMutex.Unlock() deskInfo, exists := shipDesks[patp] diff --git a/goseg/config/config.go b/goseg/config/config.go index 877c16961..0072eb171 100644 --- a/goseg/config/config.go +++ b/goseg/config/config.go @@ -44,9 +44,16 @@ var ( // representation of desired/actual container states GSContainers = make(map[string]structs.ContainerState) // channel for log stream requests - DockerDir = defaults.DockerData("volumes") + "/" - confPath = filepath.Join(BasePath, "settings", "system.json") - keyPath = filepath.Join(BasePath, "settings", "session.key") + DockerDir = defaults.DockerData("volumes") + "/" + confPath = filepath.Join(BasePath, "settings", "system.json") + keyPath = filepath.Join(BasePath, "settings", "session.key") + removedSysConfigKeys = []string{ + "penpaiAllow", + "penpaiRunning", + "penpaiCores", + "penpaiModels", + "penpaiActive", + } isEMMCMachine bool confMutex sync.Mutex contMutex sync.Mutex @@ -111,6 +118,9 @@ func init() { } // add mising fields globalConfig = mergeConfigs(defaults.SysConfig(BasePath), globalConfig) + if err := pruneRemovedSysConfigKeysOnDisk(); err != nil { + zap.L().Warn(fmt.Sprintf("Unable to prune removed config keys: %v", err)) + } // wipe the sessions on each startup //globalConfig.Sessions.Authorized = make(map[string]structs.SessionInfo) globalConfig.Sessions.Unauthorized = make(map[string]structs.SessionInfo) @@ -292,6 +302,7 @@ func UpdateConf(values map[string]any) error { } // update our unmarshaled struct maps.Copy(configMap, values) + pruneRemovedSysConfigKeys(configMap) if err = persistConf(configMap); err != nil { return fmt.Errorf("Unable to persist config update: %v", err) } @@ -308,6 +319,7 @@ func ReplaceConfJSON(raw []byte) ([]byte, error) { if len(configMap) == 0 { return nil, fmt.Errorf("refusing to persist empty system configuration") } + pruneRemovedSysConfigKeys(configMap) formatted, err := json.MarshalIndent(configMap, "", " ") if err != nil { return nil, fmt.Errorf("error encoding system config: %v", err) @@ -319,6 +331,7 @@ func ReplaceConfJSON(raw []byte) ([]byte, error) { } func persistConf(configMap map[string]any) error { + pruneRemovedSysConfigKeys(configMap) BasePath := getBasePath() confPath := filepath.Join(BasePath, "settings", "system.json") tmpFile, err := os.CreateTemp(filepath.Dir(confPath), "system.json.*") @@ -357,6 +370,54 @@ func persistConf(configMap map[string]any) error { return nil } +func pruneRemovedSysConfigKeys(configMap map[string]any) { + for _, key := range removedSysConfigKeys { + delete(configMap, key) + } +} + +func pruneRemovedSysConfigKeysOnDisk() error { + file, err := os.ReadFile(confPath) + if err != nil { + return err + } + var configMap map[string]any + if err := json.Unmarshal(file, &configMap); err != nil { + return err + } + changed := false + for _, key := range removedSysConfigKeys { + if _, ok := configMap[key]; ok { + delete(configMap, key) + changed = true + } + } + if !changed { + return nil + } + tmpFile, err := os.CreateTemp(filepath.Dir(confPath), "system.json.*") + if err != nil { + return fmt.Errorf("error creating temp file: %v", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + encoder := json.NewEncoder(tmpFile) + encoder.SetIndent("", " ") + if err := encoder.Encode(configMap); err != nil { + tmpFile.Close() + return fmt.Errorf("error encoding config: %v", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("error closing temp file: %v", err) + } + if fi, err := os.Stat(tmpPath); err != nil { + return fmt.Errorf("error checking temp file: %v", err) + } else if fi.Size() == 0 { + return fmt.Errorf("refusing to persist empty configuration file") + } + return os.Rename(tmpPath, confPath) +} + // we keep map[string]structs.ContainerState in memory to keep track of the containers // eg if they're running and whether they should be @@ -669,36 +730,6 @@ func mergeConfigs(defaultConfig, customConfig structs.SysConfig) structs.SysConf mergedConfig.Salt = customConfig.Salt } - // PenpaiAllow - mergedConfig.PenpaiAllow = customConfig.PenpaiAllow || defaultConfig.PenpaiAllow - - // PenpaiCores - if customConfig.PenpaiCores != 0 { - mergedConfig.PenpaiCores = customConfig.PenpaiCores - } else { - mergedConfig.PenpaiCores = defaultConfig.PenpaiCores - } - - // PenpaiModels - // always use defaults as newest - mergedConfig.PenpaiModels = defaultConfig.PenpaiModels - - // PenpaiRunning - mergedConfig.PenpaiRunning = customConfig.PenpaiRunning - - // PenpaiActive - validModel := false - for _, model := range defaultConfig.PenpaiModels { - if strings.EqualFold(model.ModelName, customConfig.PenpaiActive) { - validModel = true - } - } - if customConfig.PenpaiActive != "" && validModel { - mergedConfig.PenpaiActive = customConfig.PenpaiActive - } else { - mergedConfig.PenpaiActive = defaultConfig.PenpaiActive - } - // 502 checker if customConfig.Disable502 { mergedConfig.Disable502 = customConfig.Disable502 diff --git a/goseg/config/hermes.go b/goseg/config/hermes.go new file mode 100644 index 000000000..eabb5d956 --- /dev/null +++ b/goseg/config/hermes.go @@ -0,0 +1,125 @@ +package config + +import ( + "encoding/json" + "fmt" + "groundseg/defaults" + "groundseg/structs" + "os" + "path/filepath" + "strings" + "sync" +) + +var ( + hermesConfig structs.HermesConfig + hermesMutex sync.RWMutex +) + +func HermesConf() structs.HermesConfig { + hermesMutex.RLock() + defer hermesMutex.RUnlock() + return hermesConfig +} + +func LoadHermesConfig() error { + path := filepath.Join(BasePath, "settings", "hermes.json") + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := CreateDefaultHermesConf(); err != nil { + return err + } + } + file, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("unable to load Hermes config: %w", err) + } + var target structs.HermesConfig + if err := json.Unmarshal(file, &target); err != nil { + return fmt.Errorf("error decoding Hermes config: %w", err) + } + applyHermesDefaults(&target) + hermesMutex.Lock() + hermesConfig = target + hermesMutex.Unlock() + return nil +} + +func CreateDefaultHermesConf() error { + defaultConfig := defaults.HermesConfig + path := filepath.Join(BasePath, "settings", "hermes.json") + if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + return err + } + file, err := os.Create(path) + if err != nil { + return err + } + defer file.Close() + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + return encoder.Encode(&defaultConfig) +} + +func UpdateHermesConfig(input structs.HermesConfig) error { + applyHermesDefaults(&input) + hermesMutex.Lock() + defer hermesMutex.Unlock() + hermesConfig = input + path := filepath.Join(BasePath, "settings", "hermes.json") + if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { + return err + } + tmpFile, err := os.CreateTemp(filepath.Dir(path), "hermes.json.*") + if err != nil { + return fmt.Errorf("error creating temp Hermes config: %v", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + encoder := json.NewEncoder(tmpFile) + encoder.SetIndent("", " ") + if err := encoder.Encode(&input); err != nil { + tmpFile.Close() + return fmt.Errorf("error encoding Hermes config: %v", err) + } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("error closing Hermes config: %v", err) + } + if fi, err := os.Stat(tmpPath); err != nil { + return fmt.Errorf("error checking Hermes config: %v", err) + } else if fi.Size() == 0 { + return fmt.Errorf("refusing to persist empty Hermes config") + } + return os.Rename(tmpPath, path) +} + +func applyHermesDefaults(target *structs.HermesConfig) { + if target.Port == 0 { + target.Port = defaults.HermesConfig.Port + } + if target.Image == "" { + target.Image = defaults.HermesConfig.Image + } + if target.HermesVersion == "" { + target.HermesVersion = defaults.HermesConfig.HermesVersion + } + if target.HermesAgentRef == "" { + target.HermesAgentRef = defaults.HermesConfig.HermesAgentRef + } + if target.TlonAdapterVersion == "" { + target.TlonAdapterVersion = defaults.HermesConfig.TlonAdapterVersion + } + if target.TlonAdapterRef == "" { + target.TlonAdapterRef = defaults.HermesConfig.TlonAdapterRef + } + if target.ModelProvider == "" { + target.ModelProvider = defaults.HermesConfig.ModelProvider + } + if target.Model == "" { + target.Model = defaults.HermesConfig.Model + } + if target.WebProvider != "" { + target.WebProvider = strings.TrimSpace(target.WebProvider) + } + target.WebURL = strings.TrimSpace(target.WebURL) + target.APIKey = strings.TrimSpace(target.APIKey) +} diff --git a/goseg/config/urbit.go b/goseg/config/urbit.go index 28f8aa964..ecae212c3 100644 --- a/goseg/config/urbit.go +++ b/goseg/config/urbit.go @@ -60,13 +60,7 @@ func LoadUrbitConfig(pier string) error { if err := json.Unmarshal(file, &targetStruct); err != nil { return fmt.Errorf("Error decoding %s JSON: %w", pier, err) } - // set startram reminder - if targetStruct.StartramReminder == nil { - targetStruct.StartramReminder = defaults.UrbitConfig.StartramReminder - } - if targetStruct.SnapTime == 0 { - targetStruct.SnapTime = 60 - } + applyUrbitDefaults(&targetStruct) structs.SyncCustomS3Domains(&targetStruct) // Store in var UrbitsConfig[pier] = targetStruct @@ -90,6 +84,7 @@ func UpdateUrbitConfig(inputConfig map[string]structs.UrbitDocker) error { defer urbitMutex.Unlock() // update UrbitsConfig with the values from inputConfig for pier, config := range inputConfig { + applyUrbitDefaults(&config) structs.SyncCustomS3Domains(&config) ver, err := getImageTagByContainerName(pier) if err == nil { @@ -148,12 +143,7 @@ func ReplaceUrbitConfigJSON(pier string, raw []byte) ([]byte, error) { if targetStruct.PierName != "" && targetStruct.PierName != pier { return nil, fmt.Errorf("pier_name %q does not match %q", targetStruct.PierName, pier) } - if targetStruct.StartramReminder == nil { - targetStruct.StartramReminder = defaults.UrbitConfig.StartramReminder - } - if targetStruct.SnapTime == 0 { - targetStruct.SnapTime = 60 - } + applyUrbitDefaults(&targetStruct) structs.SyncCustomS3Domains(&targetStruct) urbitMutex.Lock() @@ -196,6 +186,15 @@ func unmarshalUrbitDockerSafe(data []byte, target *structs.UrbitDocker) (err err return json.Unmarshal(data, target) } +func applyUrbitDefaults(target *structs.UrbitDocker) { + if target.StartramReminder == nil { + target.StartramReminder = defaults.UrbitConfig.StartramReminder + } + if target.SnapTime == 0 { + target.SnapTime = defaults.UrbitConfig.SnapTime + } +} + func UpdateUrbitConfigForPier(pier string, mutate func(*structs.UrbitDocker)) error { if err := LoadUrbitConfig(pier); err != nil { return err diff --git a/goseg/config/version.go b/goseg/config/version.go index 96316a29b..5a91ffafe 100644 --- a/goseg/config/version.go +++ b/goseg/config/version.go @@ -9,6 +9,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "go.uber.org/zap" @@ -74,7 +75,11 @@ func CheckVersion() (structs.Channel, bool) { return VersionInfo, false } } - VersionInfo = fetchedVersion.Groundseg[releaseChannel] + targetChannel, selectedChannel, exactChannel := SelectVersionChannel(fetchedVersion, releaseChannel) + if !exactChannel { + zap.L().Warn(fmt.Sprintf("Version channel %q not found; using %q", releaseChannel, selectedChannel)) + } + VersionInfo = targetChannel // debug: re-marshal and write the entire fetched version to disk confPath := filepath.Join(BasePath, "settings", "version_info.json") file, err := os.Create(confPath) @@ -98,6 +103,26 @@ func CheckVersion() (structs.Channel, bool) { return VersionInfo, false } +func SelectVersionChannel(versionStruct structs.Version, releaseChannel string) (structs.Channel, string, bool) { + releaseChannel = strings.TrimSpace(releaseChannel) + if releaseChannel == "" { + releaseChannel = "latest" + } + if versionStruct.Groundseg == nil { + return structs.Channel{}, releaseChannel, false + } + if channel, ok := versionStruct.Groundseg[releaseChannel]; ok { + return channel, releaseChannel, true + } + if channel, ok := versionStruct.Groundseg["latest"]; ok { + return channel, "latest", false + } + for channelName, channel := range versionStruct.Groundseg { + return channel, channelName, false + } + return structs.Channel{}, releaseChannel, false +} + // write the defaults.VersionInfo value to disk func CreateDefaultVersion() error { var versionInfo structs.Version diff --git a/goseg/defaults/defaults.go b/goseg/defaults/defaults.go index 5211775a3..f1ad12e74 100644 --- a/goseg/defaults/defaults.go +++ b/goseg/defaults/defaults.go @@ -84,6 +84,27 @@ var ( Arm64Sha256: "6825aecd2f123c9d4408e660aba8a72f9e547a3774350b8f4d2d9b674e99e424", } + HermesConfig = structs.HermesConfig{ + Enabled: false, + Ship: "", + Owner: "", + Port: 19119, + Image: "registry.hub.docker.com/nativeplanet/hermes-tlon:0.14.0-0.14.0", + HermesVersion: "0.14.0", + HermesAgentRef: "2ffa1c97c09317c1d066aa5708b8ad961a4ca589", + TlonAdapterVersion: "0.14.0", + TlonAdapterRef: "33112008b1f3e83816dee61020dc5d4c57770c15", + ModelProvider: "openrouter", + Model: "deepseek/deepseek-v4-flash", + ProviderAPIKey: "", + WebProvider: "", + WebAPIKey: "", + WebURL: "", + APIEnabled: false, + APIKey: "", + AccessCode: "", + } + WgConfig = structs.WgConfig{ WireguardName: "wireguard", WireguardVersion: "latest", @@ -162,52 +183,7 @@ func SysConfig(basePath string) structs.SysConfig { Pubkey: "", Privkey: "", Salt: "", - PenpaiRunning: false, - PenpaiCores: 1, SnapTime: 60, - PenpaiActive: "TinyLlama-1.1B", - PenpaiModels: []structs.Penpai{ - { - ModelTitle: "TinyLlama 1.1B", - ModelName: "TinyLlama-1.1B", - ModelUrl: "https://huggingface.co/jartine/TinyLlama-1.1B-Chat-v1.0-GGUF/resolve/main/TinyLlama-1.1B-Chat-v1.0.Q5_K_M.llamafile?download=true", - }, - { - ModelTitle: "Mistral 7B Instruct", - ModelName: "Mistral 7B Instruct", - ModelUrl: "https://huggingface.co/jartine/Mistral-7B-Instruct-v0.2-llamafile/resolve/main/mistral-7b-instruct-v0.2.Q5_K_M.llamafile?download=true", - }, - { - ModelTitle: "Mixtral 8x7B Instruct", - ModelName: "Mixtral-8x7B-Instruct", - ModelUrl: "https://huggingface.co/jartine/Mixtral-8x7B-Instruct-v0.1-llamafile/resolve/main/mixtral-8x7b-instruct-v0.1.Q5_K_M.llamafile?download=true", - }, - { - ModelTitle: "WizardCoder Python 13B", - ModelName: "WizardCoder-Python-13B", - ModelUrl: "https://huggingface.co/jartine/wizardcoder-13b-python/resolve/main/wizardcoder-python-13b.llamafile?download=true", - }, - { - ModelTitle: "WizardCoder Python 34B", - ModelName: "WizardCoder-Python-34B", - ModelUrl: "https://huggingface.co/jartine/WizardCoder-Python-34B-V1.0-llamafile/resolve/main/wizardcoder-python-34b-v1.0.Q5_K_M.llamafile?download=true", - }, - { - ModelTitle: "LLaVA 1.5", - ModelName: "LLaVA-1.5", - ModelUrl: "https://huggingface.co/jartine/llava-v1.5-7B-GGUF/resolve/main/llava-v1.5-7b-q4.llamafile?download=true", - }, - { - ModelTitle: "TinyLlama 1.1B", - ModelName: "TinyLlama-1.1B", - ModelUrl: "https://huggingface.co/jartine/TinyLlama-1.1B-Chat-v1.0-GGUF/resolve/main/TinyLlama-1.1B-Chat-v1.0.Q5_K_M.llamafile?download=true", - }, - { - ModelTitle: "Rocket 3B", - ModelName: "Rocket-3B", - ModelUrl: "https://huggingface.co/jartine/rocket-3B-llamafile/resolve/main/rocket-3b.Q5_K_M.llamafile?download=true", - }, - }, } return sysConfig } diff --git a/goseg/defaults/scripts.go b/goseg/defaults/scripts.go index 799e56b0a..072374b37 100644 --- a/goseg/defaults/scripts.go +++ b/goseg/defaults/scripts.go @@ -856,70 +856,6 @@ var ( wget -O - only.groundseg.app | bash; echo "Ended: $(date)" >> %s/logs/fixer.log fi`, basePath, basePath) - - RunLlama = `#!/bin/bash - - # Check if the MODEL environment variable is set - if [ -z "$MODEL" ] - then - echo "Please set the MODEL_FILE environment variable" - exit 1 - fi - - # Check if the MODEL_DOWNLOAD_URL environment variable is set - if [ -z "$MODEL_DOWNLOAD_URL" ] - then - echo "Please set the MODEL_DOWNLOAD_URL environment variable" - exit 1 - fi - - # Check if the model file exists - if [ ! -f $MODEL ]; then - echo "Model file not found. Downloading..." - # Check if curl is installed - if ! [ -x "$(command -v curl)" ]; then - echo "curl is not installed. Installing..." - apt-get update --yes --quiet - apt-get install --yes --quiet curl - fi - # Download the model file - curl -L -o $MODEL $MODEL_DOWNLOAD_URL - if [ $? -ne 0 ]; then - echo "Download failed. Trying with TLS 1.2..." - curl -L --tlsv1.2 -o $MODEL $MODEL_DOWNLOAD_URL - fi - else - echo "$MODEL model found." - fi - - # Build the project - make build - - # Get the number of available CPU threads - n_threads=$(grep -c ^processor /proc/cpuinfo) - - # Define context window - n_ctx=4096 - - # Offload everything to CPU - n_gpu_layers=0 - - # Define batch size based on total RAM - total_ram=$(cat /proc/meminfo | grep MemTotal | awk '{print $2}') - n_batch=2096 - if [ $total_ram -lt 8000000 ]; then - n_batch=1024 - fi - - # Display configuration information - echo "Initializing server with:" - echo "Batch size: $n_batch" - echo "Number of CPU threads: $n_threads" - echo "Number of GPU layers: $n_gpu_layers" - echo "Context window: $n_ctx" - - # Run the server - exec python3 -m llama_cpp.server --n_ctx $n_ctx --n_threads $n_threads --n_gpu_layers $n_gpu_layers --n_batch $n_batch` ) func getBasePath() string { diff --git a/goseg/docker/docker.go b/goseg/docker/docker.go index 63c502da8..f885f5480 100644 --- a/goseg/docker/docker.go +++ b/goseg/docker/docker.go @@ -27,7 +27,9 @@ import ( var ( VolumeDir = config.DockerDir UTransBus = make(chan structs.UrbitTransition, 100) // urbit transition bus + HermesTransBus = make(chan structs.Event, 100) // hermes profile transition bus SysTransBus = make(chan structs.SystemTransition, 100) // system transition bus + UpdateCheckBus = make(chan struct{}, 1) // manual version-server update checks NewShipTransBus = make(chan structs.NewShipTransition, 100) // transition event bus ImportShipTransBus = make(chan structs.UploadTransition, 100) // transition event bus ContainerStats = make(map[string]structs.ContainerStats) // used for broadcast @@ -238,15 +240,17 @@ func WriteFileToVolume(name string, file string, content string) error { return errmsg } defer cli.Close() - // Inspect volume vol, err := cli.VolumeInspect(context.Background(), name) if err != nil { - errmsg := fmt.Errorf("Failed to inspect volume: %v : %v", name, err) - return errmsg + if _, createErr := cli.VolumeCreate(context.Background(), volumetypes.CreateOptions{Name: name}); createErr != nil { + return fmt.Errorf("Failed to create docker volume: %v : %v", name, createErr) + } + vol, err = cli.VolumeInspect(context.Background(), name) + if err != nil { + return fmt.Errorf("Failed to inspect volume: %v : %v", name, err) + } } - // Get volume directory path fullPath := filepath.Join(vol.Mountpoint, file) - // Write to file err = os.WriteFile(fullPath, []byte(content), 0644) if err != nil { errmsg := fmt.Errorf("Failed to write to volume: %v : %v", name, err) @@ -256,6 +260,25 @@ func WriteFileToVolume(name string, file string, content string) error { return nil } +// ReadFileFromVolume reads a file from a Docker volume. +func ReadFileFromVolume(name string, file string) ([]byte, error) { + cli, err := dockerclient.New() + if err != nil { + return nil, fmt.Errorf("Failed to create docker client: %v : %v", name, err) + } + defer cli.Close() + vol, err := cli.VolumeInspect(context.Background(), name) + if err != nil { + return nil, fmt.Errorf("Failed to inspect volume: %v : %v", name, err) + } + fullPath := filepath.Join(vol.Mountpoint, file) + content, err := os.ReadFile(fullPath) + if err != nil { + return nil, fmt.Errorf("Failed to read from volume: %v : %v", name, err) + } + return content, nil +} + // start a container by name + type // contructs a container.Config, then runs through whether to boot/restart/etc // saves the current container state in memory after completion @@ -288,13 +311,13 @@ func StartContainer(containerName string, containerType string) (structs.Contain if err != nil { return containerState, err } - case "wireguard": - containerConfig, hostConfig, err = wgContainerConf() + case "hermes": + containerConfig, hostConfig, err = hermesContainerConf(containerName) if err != nil { return containerState, err } - case "llama-api": - containerConfig, hostConfig, err = llamaApiContainerConf() + case "wireguard": + containerConfig, hostConfig, err = wgContainerConf() if err != nil { return containerState, err } @@ -305,7 +328,19 @@ func StartContainer(containerName string, containerType string) (structs.Contain var imageInfo map[string]string desiredImage := containerConfig.Image desiredImageID := "" - if containerType == "minio" { + if containerType == "hermes" { + if desiredImage == "" { + return containerState, fmt.Errorf("empty image ref for %s", containerName) + } + installed, err := ImageRefExists(desiredImage) + if err != nil { + return containerState, err + } + if !installed { + return containerState, fmt.Errorf("Hermes image %s is not installed", desiredImage) + } + imageInfo = map[string]string{"hash": ""} + } else if containerType == "minio" { if desiredImage == "" { return containerState, fmt.Errorf("empty image ref for %s", containerName) } @@ -319,11 +354,21 @@ func StartContainer(containerName string, containerType string) (structs.Contain if err != nil { return containerState, err } + versionServerImage := fmt.Sprintf("%s:%s@sha256:%s", imageInfo["repo"], imageInfo["tag"], imageInfo["hash"]) + if strings.TrimSpace(desiredImage) == "" { + desiredImage = versionServerImage + } + imageInfo = imageInfoFromImageRef(desiredImage, imageInfo) // check if the desired image is available locally - desiredImage = fmt.Sprintf("%s:%s@sha256:%s", imageInfo["repo"], imageInfo["tag"], imageInfo["hash"]) - _, err = PullImageIfNotExist(desiredImage, imageInfo) - if err != nil { - return containerState, err + if desiredImage == versionServerImage && imageInfo["hash"] != "" { + _, err = PullImageIfNotExist(desiredImage, imageInfo) + if err != nil { + return containerState, err + } + } else { + if err := PullImageByRef(desiredImage); err != nil { + return containerState, err + } } if desiredImageID, err = getLocalImageID(desiredImage, imageInfo); err != nil { zap.L().Warn(fmt.Sprintf("Unable to inspect desired image %s: %v", desiredImage, err)) @@ -351,6 +396,21 @@ func StartContainer(containerName string, containerType string) (structs.Contain } msg := fmt.Sprintf("%s started with image %s", containerName, desiredImage) zap.L().Info(msg) + case containerConfigChanged(existingContainer, containerConfig): + err := cli.ContainerRemove(ctx, containerName, container.RemoveOptions{Force: true}) + if err != nil { + return containerState, err + } + _, err = cli.ContainerCreate(ctx, &containerConfig, &hostConfig, nil, nil, containerName) + if err != nil { + return containerState, err + } + err = cli.ContainerStart(ctx, containerName, container.StartOptions{}) + if err != nil { + return containerState, err + } + msg := fmt.Sprintf("Recreated %s with updated container config", containerName) + zap.L().Info(msg) case existingContainer.State == "exited": err := cli.ContainerRemove(ctx, containerName, container.RemoveOptions{Force: true}) if err != nil { @@ -434,6 +494,18 @@ func StartContainer(containerName string, containerType string) (structs.Contain return containerState, err } +func containerConfigChanged(existingContainer *container.Summary, desiredConfig container.Config) bool { + if existingContainer == nil || len(desiredConfig.Labels) == 0 { + return false + } + for key, desiredValue := range desiredConfig.Labels { + if existingContainer.Labels[key] != desiredValue { + return true + } + } + return false +} + // create a stopped container func CreateContainer(containerName string, containerType string) (structs.ContainerState, error) { var containerState structs.ContainerState @@ -457,13 +529,13 @@ func CreateContainer(containerName string, containerType string) (structs.Contai if err != nil { return containerState, err } - case "wireguard": - containerConfig, hostConfig, err = wgContainerConf() + case "hermes": + containerConfig, hostConfig, err = hermesContainerConf(containerName) if err != nil { return containerState, err } - case "llama-api": - containerConfig, hostConfig, err = llamaApiContainerConf() + case "wireguard": + containerConfig, hostConfig, err = wgContainerConf() if err != nil { return containerState, err } @@ -472,7 +544,19 @@ func CreateContainer(containerName string, containerType string) (structs.Contai return containerState, errmsg } var desiredImage string - if containerType == "minio" { + if containerType == "hermes" { + desiredImage = containerConfig.Image + if desiredImage == "" { + return containerState, fmt.Errorf("empty image ref for %s", containerName) + } + installed, err := ImageRefExists(desiredImage) + if err != nil { + return containerState, err + } + if !installed { + return containerState, fmt.Errorf("Hermes image %s is not installed", desiredImage) + } + } else if containerType == "minio" { desiredImage = containerConfig.Image if desiredImage == "" { return containerState, fmt.Errorf("empty image ref for %s", containerName) @@ -485,11 +569,21 @@ func CreateContainer(containerName string, containerType string) (structs.Contai if err != nil { return containerState, err } + versionServerImage := fmt.Sprintf("%s:%s@sha256:%s", imageInfo["repo"], imageInfo["tag"], imageInfo["hash"]) + if strings.TrimSpace(desiredImage) == "" { + desiredImage = versionServerImage + } + imageInfo = imageInfoFromImageRef(desiredImage, imageInfo) // check if the desired image is available locally - desiredImage = fmt.Sprintf("%s:%s@sha256:%s", imageInfo["repo"], imageInfo["tag"], imageInfo["hash"]) - _, err = PullImageIfNotExist(desiredImage, imageInfo) - if err != nil { - return containerState, err + if desiredImage == versionServerImage && imageInfo["hash"] != "" { + _, err = PullImageIfNotExist(desiredImage, imageInfo) + if err != nil { + return containerState, err + } + } else { + if err := PullImageByRef(desiredImage); err != nil { + return containerState, err + } } } ctx := context.Background() @@ -523,50 +617,69 @@ func CreateContainer(containerName string, containerType string) (structs.Contai // convert the version info back into json then a map lol // so we can easily get the correct repo/release channel/tag/hash func GetLatestContainerInfo(containerType string) (map[string]string, error) { - var res map[string]string - // hardcoded llama stuff for testing - res = make(map[string]string) - if containerType == "llama-api" { - res["tag"] = "dev" - res["hash"] = "ac2dcfac72bc3d8ee51ee255edecc10072ef9c0f958120971c00be5f4944a6fa" - res["repo"] = "nativeplanet/llama-gpt" - return res, nil - } arch := config.Architecture hashLabel := arch + "_sha256" - versionInfo := config.VersionInfo - jsonData, err := json.Marshal(versionInfo) - if err != nil { - return res, err - } - // Convert JSON to map - var m map[string]any - err = json.Unmarshal(jsonData, &m) - if err != nil { - return res, err + detail, ok := containerVersionDetails(config.VersionInfo, containerType) + if !ok || strings.TrimSpace(detail.Tag) == "" || strings.TrimSpace(detail.Repo) == "" { + localVersion := config.LocalVersion() + releaseChannel := config.Conf().UpdateBranch + channel, selectedChannel, exactChannel := config.SelectVersionChannel(localVersion, releaseChannel) + if !exactChannel { + zap.L().Warn(fmt.Sprintf("Version channel %q not found locally; using %q", releaseChannel, selectedChannel)) + } + detail, ok = containerVersionDetails(channel, containerType) } - containerData, ok := m[containerType].(map[string]any) if !ok { - return nil, fmt.Errorf("%s data is not a map", containerType) + return nil, fmt.Errorf("%s data is not configured", containerType) } - tag, ok := containerData["tag"].(string) - if !ok { - return nil, fmt.Errorf("'tag' is not a string") + tag := strings.TrimSpace(detail.Tag) + if tag == "" { + return nil, fmt.Errorf("%s tag is empty", containerType) } - hashValue, ok := containerData[hashLabel].(string) - if !ok { - return nil, fmt.Errorf("'%s' is not a string", hashLabel) + repo := strings.TrimSpace(detail.Repo) + if repo == "" { + return nil, fmt.Errorf("%s repo is empty", containerType) } - repo, ok := containerData["repo"].(string) - if !ok { - return nil, fmt.Errorf("'repo' is not a string") + hashValue := strings.TrimSpace(detail.Amd64Sha256) + if arch != "amd64" { + hashValue = strings.TrimSpace(detail.Arm64Sha256) + } + if hashValue == "" { + return nil, fmt.Errorf("%s %s is empty", containerType, hashLabel) + } + return map[string]string{ + "tag": tag, + "hash": hashValue, + "repo": repo, + "type": containerType, + }, nil +} + +func containerVersionDetails(channel structs.Channel, containerType string) (structs.VersionDetails, bool) { + switch strings.ToLower(strings.TrimSpace(containerType)) { + case "groundseg": + return channel.Groundseg, true + case "manual": + return channel.Manual, true + case "rustfs": + return channel.Rustfs, true + case "minio": + return channel.Minio, true + case "miniomc", "mc": + return channel.Miniomc, true + case "netdata": + return channel.Netdata, true + case "vere": + return channel.Vere, true + case "hermes": + return channel.Hermes, true + case "webui": + return channel.Webui, true + case "wireguard": + return channel.Wireguard, true + default: + return structs.VersionDetails{}, false } - res = make(map[string]string) - res["tag"] = tag - res["hash"] = hashValue - res["repo"] = repo - res["type"] = containerType - return res, nil } // stop a container with the name @@ -598,6 +711,21 @@ func StopContainerByName(containerName string) error { return fmt.Errorf("container with name %s not found", containerName) } +func RestartContainerByName(containerName string) error { + ctx := context.Background() + cli, err := dockerclient.New() + if err != nil { + return err + } + defer cli.Close() + timeout := 10 + if err := cli.ContainerRestart(ctx, containerName, container.StopOptions{Timeout: &timeout}); err != nil { + return fmt.Errorf("failed to restart container %s: %v", containerName, err) + } + zap.L().Info(fmt.Sprintf("Successfully restarted container %s", containerName)) + return nil +} + // pull the image if it doesn't exist locally func PullImageIfNotExist(desiredImage string, imageInfo map[string]string) (bool, error) { ctx := context.Background() @@ -623,6 +751,45 @@ func PullImageIfNotExist(desiredImage string, imageInfo map[string]string) (bool // pull image by reference (tag or digest) if missing locally func PullImageByRef(imageRef string) error { + return PullImageByRefWithProgress(imageRef, nil) +} + +type imagePullMessage struct { + Status string `json:"status"` + ID string `json:"id"` + Error string `json:"error"` + ProgressDetail struct { + Current int64 `json:"current"` + Total int64 `json:"total"` + } `json:"progressDetail"` +} + +type imageLayerProgress struct { + current int64 + total int64 +} + +func ImageRefExists(imageRef string) (bool, error) { + if strings.TrimSpace(imageRef) == "" { + return false, nil + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cli, err := dockerclient.New() + if err != nil { + return false, err + } + defer cli.Close() + _, ok := inspectImageRefs(ctx, cli, dockerHubRefAliases(imageRef)) + return ok, nil +} + +// PullImageByRefWithProgress pulls an image by tag or digest and reports coarse progress. +func PullImageByRefWithProgress(imageRef string, progress func(string)) error { + imageRef = strings.TrimSpace(imageRef) + if imageRef == "" { + return fmt.Errorf("empty image ref") + } ctx := context.Background() cli, err := dockerclient.New() if err != nil { @@ -631,18 +798,103 @@ func PullImageByRef(imageRef string) error { defer cli.Close() if _, ok := inspectImageRefs(ctx, cli, dockerHubRefAliases(imageRef)); ok { + emitImagePullProgress(progress, "installed") return nil } + zap.L().Info(fmt.Sprintf("Pulling Docker image %s", imageRef)) + emitImagePullProgress(progress, "pulling") resp, err := cli.ImagePull(ctx, imageRef, imagetypes.PullOptions{}) if err != nil { return err } defer resp.Close() - _, _ = io.Copy(ioutil.Discard, resp) + layers := map[string]imageLayerProgress{} + lastPercent := -1 + decoder := json.NewDecoder(resp) + for { + var msg imagePullMessage + if err := decoder.Decode(&msg); err != nil { + if err == io.EOF { + break + } + return fmt.Errorf("error reading image pull progress for %s: %v", imageRef, err) + } + if msg.Error != "" { + return fmt.Errorf("failed to pull image %s: %s", imageRef, msg.Error) + } + if msg.ID != "" && msg.ProgressDetail.Total > 0 { + layers[msg.ID] = imageLayerProgress{ + current: msg.ProgressDetail.Current, + total: msg.ProgressDetail.Total, + } + percent := max(imagePullPercent(layers), lastPercent) + if percent != lastPercent { + lastPercent = percent + emitImagePullProgress(progress, fmt.Sprintf("pulling %d%%", percent)) + } + continue + } + status := strings.ToLower(strings.TrimSpace(msg.Status)) + if msg.ID == "" && status != "" { + emitImagePullProgress(progress, status) + } + } + emitImagePullProgress(progress, "installed") + zap.L().Info(fmt.Sprintf("Docker image %s installed", imageRef)) return nil } +func imagePullPercent(layers map[string]imageLayerProgress) int { + var current int64 + var total int64 + for _, layer := range layers { + current += layer.current + total += layer.total + } + if total <= 0 { + return 0 + } + percent := int(float64(current) / float64(total) * 100) + if percent > 100 { + return 100 + } + return percent +} + +func emitImagePullProgress(progress func(string), status string) { + if progress != nil && strings.TrimSpace(status) != "" { + progress(status) + } +} + +func imageInfoFromImageRef(imageRef string, fallback map[string]string) map[string]string { + info := map[string]string{ + "repo": fallback["repo"], + "tag": fallback["tag"], + "hash": fallback["hash"], + } + ref := strings.TrimSpace(imageRef) + if ref == "" { + return info + } + if beforeDigest, digest, ok := strings.Cut(ref, "@sha256:"); ok { + ref = beforeDigest + info["hash"] = digest + } else { + info["hash"] = "" + } + lastSlash := strings.LastIndex(ref, "/") + lastColon := strings.LastIndex(ref, ":") + if lastColon > lastSlash { + info["repo"] = ref[:lastColon] + info["tag"] = ref[lastColon+1:] + } else if ref != "" { + info["repo"] = ref + } + return info +} + func getLocalImageID(desiredImage string, imageInfo map[string]string) (string, error) { ctx := context.Background() cli, err := dockerclient.New() diff --git a/goseg/docker/hermes.go b/goseg/docker/hermes.go new file mode 100644 index 000000000..f4cec8e07 --- /dev/null +++ b/goseg/docker/hermes.go @@ -0,0 +1,751 @@ +package docker + +import ( + "fmt" + "groundseg/config" + "groundseg/structs" + "net" + neturl "net/url" + "os" + "slices" + "strings" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" + "github.com/docker/go-connections/nat" + "go.uber.org/zap" +) + +const ( + HermesContainerName = "hermes" + HermesDataVolumeName = "hermes" + HermesWorkspaceVolumeName = "hermes_workspace" + HermesTlonSkillDir = "/opt/data/tlon-skill" + hermesConfigVersionLabel = "nativeplanet.groundseg.hermes.config-version" + hermesConfigVersion = "2026-06-28-hermes-tmux-searxng" + DefaultHermesImage = "registry.hub.docker.com/nativeplanet/hermes-tlon:0.14.0-0.14.0" + DefaultHermesModelProvider = "openrouter" + DefaultHermesModel = "deepseek/deepseek-v4-flash" + DefaultHermesVersion = "0.14.0" + DefaultHermesAgentRef = "2ffa1c97c09317c1d066aa5708b8ad961a4ca589" + DefaultHermesTlonAdapterVersion = "0.14.0" + DefaultHermesTlonAdapterRef = "33112008b1f3e83816dee61020dc5d4c57770c15" + DefaultHermesDashboardHostPort = 19119 + HermesDashboardContainerPort = 9119 +) + +type hermesShipTarget struct { + URL string + ExtraHosts []string +} + +type hermesModelProvider struct { + Name string + APIKeyEnv string +} + +type hermesWebProvider struct { + Name string + APIKeyEnv string + URLEnv string + AliasEnv []string + SearchBackend string + ExtractBackend string +} + +var hermesModelProviders = []hermesModelProvider{ + {Name: "ai-gateway", APIKeyEnv: "AI_GATEWAY_API_KEY"}, + {Name: "alibaba", APIKeyEnv: "DASHSCOPE_API_KEY"}, + {Name: "alibaba-coding-plan", APIKeyEnv: "ALIBABA_CODING_PLAN_API_KEY"}, + {Name: "anthropic", APIKeyEnv: "ANTHROPIC_API_KEY"}, + {Name: "arcee", APIKeyEnv: "ARCEEAI_API_KEY"}, + {Name: "deepseek", APIKeyEnv: "DEEPSEEK_API_KEY"}, + {Name: "gmi", APIKeyEnv: "GMI_API_KEY"}, + {Name: "huggingface", APIKeyEnv: "HF_TOKEN"}, + {Name: "kilocode", APIKeyEnv: "KILOCODE_API_KEY"}, + {Name: "kimi-coding", APIKeyEnv: "KIMI_API_KEY"}, + {Name: "kimi-coding-cn", APIKeyEnv: "KIMI_CN_API_KEY"}, + {Name: "nous", APIKeyEnv: "NOUS_API_KEY"}, + {Name: "novita", APIKeyEnv: "NOVITA_API_KEY"}, + {Name: "nvidia", APIKeyEnv: "NVIDIA_API_KEY"}, + {Name: "ollama-cloud", APIKeyEnv: "OLLAMA_API_KEY"}, + {Name: "openai", APIKeyEnv: "OPENAI_API_KEY"}, + {Name: "opencode-go", APIKeyEnv: "OPENCODE_GO_API_KEY"}, + {Name: "opencode-zen", APIKeyEnv: "OPENCODE_ZEN_API_KEY"}, + {Name: "openrouter", APIKeyEnv: "OPENROUTER_API_KEY"}, + {Name: "stepfun", APIKeyEnv: "STEPFUN_API_KEY"}, + {Name: "xai", APIKeyEnv: "XAI_API_KEY"}, + {Name: "xiaomi", APIKeyEnv: "XIAOMI_API_KEY"}, + {Name: "zai", APIKeyEnv: "GLM_API_KEY"}, +} + +var hermesWebProviders = []hermesWebProvider{ + {Name: "brave-free", APIKeyEnv: "BRAVE_SEARCH_API_KEY", AliasEnv: []string{"BRAVE_API_KEY"}, SearchBackend: "brave-free"}, + {Name: "exa", APIKeyEnv: "EXA_API_KEY", SearchBackend: "exa", ExtractBackend: "exa"}, + {Name: "firecrawl", APIKeyEnv: "FIRECRAWL_API_KEY", SearchBackend: "firecrawl", ExtractBackend: "firecrawl"}, + {Name: "parallel", APIKeyEnv: "PARALLEL_API_KEY", SearchBackend: "parallel", ExtractBackend: "parallel"}, + {Name: "searxng", URLEnv: "SEARXNG_URL", SearchBackend: "searxng"}, + {Name: "tavily", APIKeyEnv: "TAVILY_API_KEY", SearchBackend: "tavily", ExtractBackend: "tavily"}, + {Name: "xai", APIKeyEnv: "XAI_API_KEY", SearchBackend: "xai"}, +} + +func HermesImageOrDefault(image string) string { + if image = strings.TrimSpace(image); image != "" { + return image + } + if image, err := HermesVersionServerImage(); err == nil && image != "" { + return image + } + return DefaultHermesImage +} + +func HermesVersionServerImage() (string, error) { + info, err := GetLatestContainerInfo("hermes") + if err != nil { + return "", err + } + repo := strings.TrimSpace(info["repo"]) + tag := strings.TrimSpace(info["tag"]) + hash := strings.TrimSpace(info["hash"]) + if repo == "" || tag == "" { + return "", fmt.Errorf("Hermes version-server image is missing repo or tag") + } + image := fmt.Sprintf("%s:%s", repo, tag) + if hash != "" { + image = fmt.Sprintf("%s@sha256:%s", image, hash) + } + return image, nil +} + +func HermesUpdateAvailable(configuredImage string) bool { + latestImage, err := HermesVersionServerImage() + if err != nil || latestImage == "" { + return false + } + currentImage := HermesImageOrDefault(configuredImage) + return strings.TrimSpace(currentImage) != strings.TrimSpace(latestImage) +} + +func HermesModelProviderOrDefault(provider string) string { + if provider = NormalizeHermesModelProvider(provider); provider != "" { + return provider + } + return DefaultHermesModelProvider +} + +func NormalizeHermesModelProvider(provider string) string { + provider = strings.ToLower(strings.TrimSpace(provider)) + for _, supported := range hermesModelProviders { + if provider == supported.Name { + return supported.Name + } + } + return "" +} + +func HermesProviderAPIKeyEnv(provider string) string { + provider = NormalizeHermesModelProvider(provider) + for _, supported := range hermesModelProviders { + if provider == supported.Name { + return supported.APIKeyEnv + } + } + return "" +} + +func HermesWebProviderOrEmpty(provider string) string { + return NormalizeHermesWebProvider(provider) +} + +func NormalizeHermesWebProvider(provider string) string { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" || provider == "off" || provider == "none" { + return "" + } + for _, supported := range hermesWebProviders { + if provider == supported.Name { + return supported.Name + } + } + return "" +} + +func HermesWebProviderAPIKeyEnv(provider string) string { + provider = NormalizeHermesWebProvider(provider) + for _, supported := range hermesWebProviders { + if provider == supported.Name { + return supported.APIKeyEnv + } + } + return "" +} + +func HermesWebProviderURLEnv(provider string) string { + provider = NormalizeHermesWebProvider(provider) + for _, supported := range hermesWebProviders { + if provider == supported.Name { + return supported.URLEnv + } + } + return "" +} + +func HermesWebProviderConfig(provider string) (hermesWebProvider, bool) { + provider = NormalizeHermesWebProvider(provider) + for _, supported := range hermesWebProviders { + if provider == supported.Name { + return supported, true + } + } + return hermesWebProvider{}, false +} + +func HermesModelOrDefault(model string) string { + if model = strings.TrimSpace(model); model != "" { + return model + } + return DefaultHermesModel +} + +func HermesVersionOrDefault(version string) string { + if version = strings.TrimSpace(version); version != "" { + return version + } + return DefaultHermesVersion +} + +func HermesAgentRefOrDefault(ref string) string { + if ref = strings.TrimSpace(ref); ref != "" { + return ref + } + return DefaultHermesAgentRef +} + +func HermesTlonAdapterVersionOrDefault(version string) string { + if version = strings.TrimSpace(version); version != "" { + return version + } + return DefaultHermesTlonAdapterVersion +} + +func HermesTlonAdapterRefOrDefault(ref string) string { + if ref = strings.TrimSpace(ref); ref != "" { + return ref + } + return DefaultHermesTlonAdapterRef +} + +func NormalizeHermesShip(ship string) string { + ship = strings.TrimSpace(ship) + if ship == "" { + return "" + } + if !strings.HasPrefix(ship, "~") { + ship = "~" + ship + } + return ship +} + +func LoadHermes() error { + zap.L().Info("Loading Hermes") + if err := config.LoadHermesConfig(); err != nil { + return err + } + hermesConf := config.HermesConf() + if !hermesConf.Enabled { + stopDisabledHermes() + return nil + } + if strings.TrimSpace(hermesConf.AccessCode) == "" { + zap.L().Warn("Hermes is enabled but no access code is stored; restart Hermes from Profile") + return nil + } + info, err := StartContainer(HermesContainerName, "hermes") + if err != nil { + return err + } + config.UpdateContainerState(HermesContainerName, info) + return nil +} + +func stopDisabledHermes() { + existing, err := FindContainer(HermesContainerName) + if err == nil && existing != nil && existing.State == "running" { + if stopErr := StopContainerByName(HermesContainerName); stopErr != nil { + zap.L().Warn(fmt.Sprintf("Unable to stop disabled Hermes container: %v", stopErr)) + } + } + if containerState, exists := config.GetContainerState()[HermesContainerName]; exists { + containerState.DesiredStatus = "stopped" + config.UpdateContainerState(HermesContainerName, containerState) + } +} + +func hermesContainerConf(containerName string) (container.Config, container.HostConfig, error) { + var containerConfig container.Config + var hostConfig container.HostConfig + if containerName != HermesContainerName { + return containerConfig, hostConfig, fmt.Errorf("invalid Hermes container name: %s", containerName) + } + if err := config.LoadHermesConfig(); err != nil { + return containerConfig, hostConfig, err + } + hermesConf := config.HermesConf() + if !hermesConf.Enabled { + return containerConfig, hostConfig, fmt.Errorf("Hermes is not enabled") + } + owner := NormalizeHermesShip(hermesConf.Owner) + if owner == "" { + return containerConfig, hostConfig, fmt.Errorf("Hermes owner is not configured") + } + attachedShip := NormalizeHermesShip(hermesConf.Ship) + if attachedShip == "" { + return containerConfig, hostConfig, fmt.Errorf("Hermes ship is not configured") + } + accessCode := strings.TrimSpace(hermesConf.AccessCode) + if accessCode == "" { + return containerConfig, hostConfig, fmt.Errorf("Hermes access code is not configured") + } + if hermesConf.Port <= 0 { + return containerConfig, hostConfig, fmt.Errorf("Hermes dashboard port is not configured") + } + patp := strings.TrimPrefix(attachedShip, "~") + if err := config.LoadUrbitConfig(patp); err != nil { + return containerConfig, hostConfig, err + } + shipConf := config.UrbitConf(patp) + shipTarget, err := hermesShipTargetForContainer(shipConf) + if err != nil { + return containerConfig, hostConfig, err + } + shipURL := shipTarget.URL + attachedShipBare := strings.TrimPrefix(attachedShip, "~") + environment := []string{ + "HERMES_HOME=/opt/data", + "HERMES_WORKSPACE=/workspace", + "HERMES_WORKSPACE_DIR=/workspace", + "HERMES_CONTAINER_HOME=/workspace/home", + "HERMES_OPENROUTER_CACHE=false", + "HERMES_TLON_ADAPTER_DIR=/opt/tlon-apps/packages/hermes-tlon-adapter", + "HERMES_INTERACTIVE=true", + "HERMES_GATEWAY_SESSION=true", + "HERMES_EXEC_ASK=true", + "LCM_DATABASE_PATH=/opt/data/lcm.db", + "HOME=/workspace/home", + "HERMES_DASHBOARD=1", + "HERMES_DASHBOARD_HOST=0.0.0.0", + fmt.Sprintf("HERMES_DASHBOARD_PORT=%d", HermesDashboardContainerPort), + fmt.Sprintf("API_SERVER_ENABLED=%t", hermesConf.APIEnabled), + "HERMES_ALLOW_CONFIG_WRITE=true", + fmt.Sprintf("HERMES_INFERENCE_PROVIDER=%s", HermesModelProviderOrDefault(hermesConf.ModelProvider)), + fmt.Sprintf("HERMES_MODEL_PROVIDER=%s", HermesModelProviderOrDefault(hermesConf.ModelProvider)), + fmt.Sprintf("HERMES_MODEL=%s", HermesModelOrDefault(hermesConf.Model)), + fmt.Sprintf("HERMES_AGENT_VERSION=%s", HermesVersionOrDefault(hermesConf.HermesVersion)), + fmt.Sprintf("HERMES_AGENT_REF=%s", HermesAgentRefOrDefault(hermesConf.HermesAgentRef)), + fmt.Sprintf("HERMES_TLON_ADAPTER_VERSION=%s", HermesTlonAdapterVersionOrDefault(hermesConf.TlonAdapterVersion)), + fmt.Sprintf("HERMES_TLON_ADAPTER_REF=%s", HermesTlonAdapterRefOrDefault(hermesConf.TlonAdapterRef)), + "TLON_TELEMETRY=false", + "HERMES_TLON_TOOLSET=tlon", + "HERMES_TLON_TOOLSETS=tlon,file,terminal,web,browser,skills,todo,cronjob,context_engine", + "TERMINAL_ENV=local", + "TERMINAL_CWD=/workspace", + "TERMINAL_LOCAL_PERSISTENT=true", + "TERMINAL_TIMEOUT=180", + "TERMINAL_MAX_FOREGROUND_TIMEOUT=900", + "TLON_SKILL_PATH=/opt/tlon-apps/packages/tlon-skill/SKILL.md", + fmt.Sprintf("TLON_SKILL_DIR=%s", HermesTlonSkillDir), + "TLON_CLI=/usr/local/bin/tlon", + fmt.Sprintf("TLON_CONFIG_FILE=%s", hermesTlonShipConfigPath(attachedShipBare)), + fmt.Sprintf("TLON_NODE_URL=%s", shipURL), + fmt.Sprintf("TLON_NODE_ID=%s", attachedShip), + fmt.Sprintf("TLON_ACCESS_CODE=%s", accessCode), + fmt.Sprintf("TLON_OWNER=%s", owner), + fmt.Sprintf("TLON_OWNER_SHIP=%s", owner), + fmt.Sprintf("TLON_OWNER_URL=%s", shipURL), + fmt.Sprintf("TLON_HOME_CHANNEL=%s", owner), + fmt.Sprintf("TLON_ALLOWED_USERS=%s", owner), + fmt.Sprintf("TLON_DM_ALLOWLIST=%s", owner), + fmt.Sprintf("TLON_DEFAULT_AUTHORIZED_SHIPS=%s", owner), + fmt.Sprintf("TLON_GROUP_INVITE_ALLOWLIST=%s", owner), + "TLON_BOT_ALIASES=", + "TLON_BOT_MENTIONS=", + "TLON_CHANNELS=", + "TLON_CHANNEL_RULES={}", + "TLON_AUTO_DISCOVER=true", + "TLON_AUTO_ACCEPT_DM_INVITES=true", + "TLON_AUTO_ACCEPT_GROUP_INVITES=true", + "TLON_ALLOW_ALL_USERS=false", + "TLON_DM_POLL_ENABLED=true", + "TLON_OWNER_LISTEN=true", + "TLON_OWNER_LISTEN_ENABLED=true", + "TLON_REQUIRE_MENTION=true", + "TLON_MAX_CONSECUTIVE_BOT_RESPONSES=2", + fmt.Sprintf("URBIT_URL=%s", shipURL), + fmt.Sprintf("URBIT_SHIP=%s", attachedShip), + fmt.Sprintf("URBIT_CODE=%s", accessCode), + fmt.Sprintf("TLON_URL=%s", shipURL), + fmt.Sprintf("TLON_CODE=%s", accessCode), + fmt.Sprintf("TLON_SHIP=%s", attachedShipBare), + fmt.Sprintf("TLON_SHIP_URL=%s", shipURL), + fmt.Sprintf("TLON_SHIP_NAME=%s", attachedShip), + fmt.Sprintf("TLON_SHIP_CODE=%s", accessCode), + } + apiKeyEnv := HermesProviderAPIKeyEnv(hermesConf.ModelProvider) + apiKey := strings.TrimSpace(hermesConf.ProviderAPIKey) + if apiKeyEnv == "" { + return containerConfig, hostConfig, fmt.Errorf("unsupported Hermes provider %q", hermesConf.ModelProvider) + } + if apiKey == "" { + return containerConfig, hostConfig, fmt.Errorf("Hermes provider API key is not configured") + } + environment = append(environment, fmt.Sprintf("%s=%s", apiKeyEnv, apiKey)) + if apiServerKey := strings.TrimSpace(hermesConf.APIKey); hermesConf.APIEnabled && apiServerKey != "" { + environment = append(environment, fmt.Sprintf("API_SERVER_KEY=%s", apiServerKey)) + } + if webProviderName := NormalizeHermesWebProvider(hermesConf.WebProvider); webProviderName != "" { + webProvider, ok := HermesWebProviderConfig(webProviderName) + if !ok { + return containerConfig, hostConfig, fmt.Errorf("unsupported Hermes web provider %q", hermesConf.WebProvider) + } + environment = append(environment, + fmt.Sprintf("HERMES_WEB_BACKEND=%s", webProvider.SearchBackend), + fmt.Sprintf("HERMES_WEB_SEARCH_BACKEND=%s", webProvider.SearchBackend), + ) + webAPIKey := strings.TrimSpace(hermesConf.WebAPIKey) + if webProvider.APIKeyEnv != "" { + if webAPIKey == "" { + return containerConfig, hostConfig, fmt.Errorf("Hermes web API key is not configured") + } + environment = append(environment, fmt.Sprintf("%s=%s", webProvider.APIKeyEnv, webAPIKey)) + } + if webProvider.URLEnv != "" { + webURL, urlExtraHosts, err := hermesURLForContainer(hermesConf.WebURL) + if err != nil { + return containerConfig, hostConfig, err + } + if webURL == "" { + return containerConfig, hostConfig, fmt.Errorf("Hermes web URL is not configured") + } + environment = append(environment, fmt.Sprintf("%s=%s", webProvider.URLEnv, webURL)) + shipTarget.ExtraHosts = appendUniqueStrings(shipTarget.ExtraHosts, urlExtraHosts...) + } + if webProvider.ExtractBackend != "" { + environment = append(environment, fmt.Sprintf("HERMES_WEB_EXTRACT_BACKEND=%s", webProvider.ExtractBackend)) + } + for _, alias := range webProvider.AliasEnv { + environment = append(environment, fmt.Sprintf("%s=%s", alias, webAPIKey)) + } + } + zap.L().Info(fmt.Sprintf("Configuring Hermes for %s via %s with owner %s", attachedShip, shipURL, owner)) + + dashboardPort := nat.Port(fmt.Sprintf("%d/tcp", HermesDashboardContainerPort)) + containerConfig = container.Config{ + Image: HermesImageOrDefault(hermesConf.Image), + Env: environment, + Cmd: []string{"bash", "-lc", hermesGatewayCommand(hermesConf)}, + Labels: map[string]string{hermesConfigVersionLabel: hermesConfigVersion}, + ExposedPorts: nat.PortSet{dashboardPort: struct{}{}}, + } + hostConfig = container.HostConfig{ + NetworkMode: "default", + ExtraHosts: shipTarget.ExtraHosts, + Mounts: []mount.Mount{ + { + Type: mount.TypeVolume, + Source: HermesDataVolumeName, + Target: "/opt/data", + }, + { + Type: mount.TypeVolume, + Source: HermesWorkspaceVolumeName, + Target: "/workspace", + }, + }, + PortBindings: nat.PortMap{ + dashboardPort: []nat.PortBinding{ + {HostIP: hermesDashboardHostIP(), HostPort: fmt.Sprintf("%d", hermesConf.Port)}, + }, + }, + } + return containerConfig, hostConfig, nil +} + +func hermesGatewayCommand(hermesConf structs.HermesConfig) string { + return fmt.Sprintf( + `skill_dir="${TLON_SKILL_DIR:-/opt/data/tlon-skill}" +ship="${TLON_NODE_ID:-${TLON_SHIP_NAME:-${URBIT_SHIP:-${TLON_SHIP:-}}}}" +case "$ship" in + "~"*) ;; + "") ship="~ship" ;; + *) ship="~$ship" ;; +esac +bare_ship="${ship#~}" +config_file="${TLON_CONFIG_FILE:-$skill_dir/ships/$bare_ship.json}" +mkdir -p "$(dirname "$config_file")" /opt/data /workspace +url="${TLON_NODE_URL:-${TLON_SHIP_URL:-${TLON_URL:-${URBIT_URL:-}}}}" +code="${TLON_ACCESS_CODE:-${TLON_SHIP_CODE:-${TLON_CODE:-${URBIT_CODE:-}}}}" +cat > "$config_file" < "$managed_env_tmp" +if [ -f /opt/data/.env ]; then + awk -F= ' + NR == FNR { + if ($0 ~ /^[A-Za-z_][A-Za-z0-9_]*=/) managed[$1] = 1 + next + } + /^[[:space:]]*(#|$)/ { print; next } + { + line = $0 + sub(/^[[:space:]]*export[[:space:]]+/, "", line) + if (line ~ /^[A-Za-z_][A-Za-z0-9_]*=/) { + key = line + sub(/=.*/, "", key) + if (managed[key]) next + } + print + } + ' "$managed_env_tmp" /opt/data/.env > /opt/data/.env.next +else + : > /opt/data/.env.next +fi +cat "$managed_env_tmp" >> /opt/data/.env.next +mv /opt/data/.env.next /opt/data/.env +rm -f "$managed_env_tmp" +chmod 600 /opt/data/.env +cp /opt/data/.env /workspace/.env +chmod 600 /workspace/.env +echo "Hermes Tlon runtime files: env=/opt/data/.env workspace_env=/workspace/.env config=$config_file" +echo "Hermes Tlon CLI: ${TLON_CLI:-tlon} ($(command -v "${TLON_CLI:-tlon}" || true))" +if ! "${TLON_CLI:-tlon}" --help >/dev/null 2>&1; then + echo "ERROR: tlon CLI failed its startup smoke check" >&2 + "${TLON_CLI:-tlon}" --help >/dev/null + exit 1 +fi + +if command -v tmux >/dev/null 2>&1; then + log_file="/opt/data/logs/gateway.log" + exit_file="/opt/data/gateway.exit" + mkdir -p "$(dirname "$log_file")" + : > "$log_file" + rm -f "$exit_file" + tmux kill-session -t hermes >/dev/null 2>&1 || true + tmux new-session -d -s hermes -n gateway "bash -lc 'set -o pipefail; hermes gateway run --replace --accept-hooks 2>&1 | tee -a /opt/data/logs/gateway.log; code=\${PIPESTATUS[0]}; echo \"\$code\" > /opt/data/gateway.exit; tmux wait-for -S hermes-gateway-exit; exit \"\$code\"'" + tmux new-window -d -t hermes -n shell "bash -l" + tmux select-window -t hermes:shell + + cleanup() { + tmux send-keys -t hermes:gateway C-c >/dev/null 2>&1 || true + sleep 2 + tmux kill-session -t hermes >/dev/null 2>&1 || true + } + trap cleanup INT TERM + + tail -n +1 -F "$log_file" & + tail_pid="$!" + tmux wait-for hermes-gateway-exit || true + kill "$tail_pid" >/dev/null 2>&1 || true + wait "$tail_pid" >/dev/null 2>&1 || true + code="1" + if [ -f "$exit_file" ]; then + code="$(cat "$exit_file")" + fi + exit "$code" +fi +exec hermes gateway run --replace --accept-hooks`, + ) +} + +func hermesTlonShipConfigPath(attachedShipBare string) string { + return fmt.Sprintf("%s/ships/%s.json", HermesTlonSkillDir, strings.TrimPrefix(attachedShipBare, "~")) +} + +func hermesShipTargetForContainer(shipConf structs.UrbitDocker) (hermesShipTarget, error) { + if shipConf.Network == "wireguard" { + remoteURL := UrbitRemoteWebURL(shipConf) + if remoteURL == "" { + return hermesShipTarget{}, fmt.Errorf("remote URL is not configured for Hermes") + } + + return hermesShipTarget{ + URL: remoteURL, + }, nil + } + if shipConf.HTTPPort <= 0 { + return hermesShipTarget{}, fmt.Errorf("HTTP port is not configured for Hermes") + } + return hermesShipTarget{ + URL: fmt.Sprintf("http://host.docker.internal:%d", shipConf.HTTPPort), + ExtraHosts: []string{"host.docker.internal:host-gateway"}, + }, nil +} + +func UrbitWebURL(localHost string, shipConf structs.UrbitDocker) string { + if remoteURL := UrbitRemoteWebURL(shipConf); remoteURL != "" { + return remoteURL + } + localHost = strings.TrimSpace(localHost) + if localHost == "" || shipConf.HTTPPort <= 0 { + return "" + } + return fmt.Sprintf("http://%s:%d", localHost, shipConf.HTTPPort) +} + +func UrbitRemoteWebURL(shipConf structs.UrbitDocker) string { + if shipConf.Network != "wireguard" { + return "" + } + remoteURL := strings.TrimSpace(shipConf.WgURL) + customURL := strings.TrimSpace(shipConf.CustomUrbitWeb) + if strings.EqualFold(customURL, "null") { + customURL = "" + } + if shipConf.ShowUrbitWeb == "custom" && customURL != "" { + remoteURL = customURL + } + return normalizeHermesURL(remoteURL) +} + +func normalizeHermesURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") { + return rawURL + } + return "https://" + rawURL +} + +func hermesURLForContainer(rawURL string) (string, []string, error) { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "", nil, nil + } + if !strings.Contains(rawURL, "://") { + rawURL = "http://" + rawURL + } + parsed, err := neturl.Parse(rawURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", nil, fmt.Errorf("invalid Hermes web URL %q", rawURL) + } + host := parsed.Hostname() + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + port := parsed.Port() + if port != "" { + parsed.Host = net.JoinHostPort("host.docker.internal", port) + } else { + parsed.Host = "host.docker.internal" + } + return strings.TrimRight(parsed.String(), "/"), []string{"host.docker.internal:host-gateway"}, nil + } + return strings.TrimRight(parsed.String(), "/"), nil, nil +} + +func appendUniqueStrings(values []string, additions ...string) []string { + for _, addition := range additions { + if addition == "" { + continue + } + exists := slices.Contains(values, addition) + if !exists { + values = append(values, addition) + } + } + return values +} + +func hermesDashboardHostIP() string { + if hostIP := strings.TrimSpace(os.Getenv("GROUNDSEG_HERMES_HOST_IP")); hostIP != "" { + return hostIP + } + ifaces, err := net.Interfaces() + if err != nil { + zap.L().Warn(fmt.Sprintf("Unable to enumerate interfaces for Hermes dashboard binding: %v", err)) + return "127.0.0.1" + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + if !isCandidateLANInterface(iface.Name) { + continue + } + addrs, err := iface.Addrs() + if err != nil { + continue + } + for _, addr := range addrs { + ip := ipFromAddr(addr) + if ip == nil || !ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() { + continue + } + return ip.String() + } + } + zap.L().Warn("Unable to find a LAN interface for Hermes dashboard binding; falling back to localhost") + return "127.0.0.1" +} + +func isCandidateLANInterface(name string) bool { + name = strings.ToLower(name) + blockedPrefixes := []string{"br-", "docker", "veth", "wg", "tun", "tap"} + for _, prefix := range blockedPrefixes { + if strings.HasPrefix(name, prefix) { + return false + } + } + return !strings.Contains(name, "tailscale") +} + +func ipFromAddr(addr net.Addr) net.IP { + var ip net.IP + switch value := addr.(type) { + case *net.IPNet: + ip = value.IP + case *net.IPAddr: + ip = value.IP + default: + return nil + } + return ip.To4() +} diff --git a/goseg/docker/hermes_test.go b/goseg/docker/hermes_test.go new file mode 100644 index 000000000..7f81d631d --- /dev/null +++ b/goseg/docker/hermes_test.go @@ -0,0 +1,249 @@ +package docker + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "groundseg/config" + "groundseg/structs" +) + +func TestHermesShipTargetUsesRemoteURLForWireguardShips(t *testing.T) { + shipConf := structs.UrbitDocker{ + Network: "wireguard", + WgURL: "sampel-palnet.nativeplanet.live", + } + if openURL := UrbitWebURL("nativeplanet.local", shipConf); openURL != "https://sampel-palnet.nativeplanet.live" { + t.Fatalf("expected clickable ship URL to use remote HTTPS URL, got %q", openURL) + } + + target, err := hermesShipTargetForContainer(shipConf) + if err != nil { + t.Fatalf("expected remote target, got error: %v", err) + } + if target.URL != "https://sampel-palnet.nativeplanet.live" { + t.Fatalf("expected remote HTTPS URL, got %q", target.URL) + } + if len(target.ExtraHosts) != 0 { + t.Fatalf("expected no extra host mapping for remote URL, got %#v", target.ExtraHosts) + } +} + +func TestHermesShipTargetUsesCustomRemoteURLWhenSelected(t *testing.T) { + shipConf := structs.UrbitDocker{ + Network: "wireguard", + WgURL: "sampel-palnet.nativeplanet.live", + CustomUrbitWeb: "chat.example.com", + ShowUrbitWeb: "custom", + } + if openURL := UrbitWebURL("nativeplanet.local", shipConf); openURL != "https://chat.example.com" { + t.Fatalf("expected clickable ship URL to use custom alias, got %q", openURL) + } + + target, err := hermesShipTargetForContainer(shipConf) + if err != nil { + t.Fatalf("expected custom remote target, got error: %v", err) + } + if target.URL != "https://chat.example.com" { + t.Fatalf("expected custom HTTPS URL, got %q", target.URL) + } +} + +func TestHermesShipTargetKeepsSchemeOnCustomRemoteURL(t *testing.T) { + target, err := hermesShipTargetForContainer(structs.UrbitDocker{ + Network: "wireguard", + WgURL: "sampel-palnet.nativeplanet.live", + CustomUrbitWeb: "https://chat.example.com", + ShowUrbitWeb: "custom", + }) + if err != nil { + t.Fatalf("expected custom remote target, got error: %v", err) + } + if target.URL != "https://chat.example.com" { + t.Fatalf("expected custom URL scheme to be preserved, got %q", target.URL) + } +} + +func TestHermesShipTargetUsesHostGatewayForLocalShips(t *testing.T) { + shipConf := structs.UrbitDocker{ + Network: "bridge", + HTTPPort: 8080, + } + if openURL := UrbitWebURL("nativeplanet.local", shipConf); openURL != "http://nativeplanet.local:8080" { + t.Fatalf("expected clickable ship URL to use local host URL, got %q", openURL) + } + + target, err := hermesShipTargetForContainer(shipConf) + if err != nil { + t.Fatalf("expected local target, got error: %v", err) + } + if target.URL != "http://host.docker.internal:8080" { + t.Fatalf("expected host-gateway URL, got %q", target.URL) + } + if len(target.ExtraHosts) != 1 || target.ExtraHosts[0] != "host.docker.internal:host-gateway" { + t.Fatalf("expected host-gateway extra host, got %#v", target.ExtraHosts) + } +} + +func TestHermesContainerAPIEnvRequiresExplicitToggle(t *testing.T) { + tests := []struct { + name string + apiEnabled bool + apiKey string + wantEnabled string + wantKey bool + }{ + { + name: "disabled omits saved key", + apiEnabled: false, + apiKey: "saved-api-key", + wantEnabled: "false", + wantKey: false, + }, + { + name: "enabled includes key", + apiEnabled: true, + apiKey: "enabled-api-key", + wantEnabled: "true", + wantKey: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupHermesContainerConfTest(t, tt.apiEnabled, tt.apiKey) + + containerConfig, _, err := hermesContainerConf(HermesContainerName) + if err != nil { + t.Fatalf("expected Hermes container config, got error: %v", err) + } + + if got, ok := envValue(containerConfig.Env, "API_SERVER_ENABLED"); !ok || got != tt.wantEnabled { + t.Fatalf("API_SERVER_ENABLED = %q, %t; want %q, true", got, ok, tt.wantEnabled) + } + gotKey, hasKey := envValue(containerConfig.Env, "API_SERVER_KEY") + if hasKey != tt.wantKey { + t.Fatalf("API_SERVER_KEY present = %t, want %t", hasKey, tt.wantKey) + } + if hasKey && gotKey != tt.apiKey { + t.Fatalf("API_SERVER_KEY = %q, want %q", gotKey, tt.apiKey) + } + if _, ok := envValue(containerConfig.Env, "TLON_HOSTING"); ok { + t.Fatalf("TLON_HOSTING should not be set") + } + }) + } +} + +func TestHermesContainerRunsGatewayInTmuxSession(t *testing.T) { + setupHermesContainerConfTest(t, false, "") + + containerConfig, _, err := hermesContainerConf(HermesContainerName) + if err != nil { + t.Fatalf("expected Hermes container config, got error: %v", err) + } + + if len(containerConfig.Cmd) != 3 || containerConfig.Cmd[0] != "bash" || containerConfig.Cmd[1] != "-lc" { + t.Fatalf("expected bash -lc container command, got %#v", containerConfig.Cmd) + } + command := containerConfig.Cmd[2] + for _, want := range []string{ + "tmux new-session -d -s hermes -n gateway", + "tmux new-window -d -t hermes -n shell", + "tmux select-window -t hermes:shell", + "hermes gateway run --replace --accept-hooks", + "/opt/data/logs/gateway.log", + } { + if !strings.Contains(command, want) { + t.Fatalf("expected Hermes command to contain %q", want) + } + } +} + +func TestHermesContainerSupportsSearXNGWebProvider(t *testing.T) { + setupHermesContainerConfTest(t, false, "") + hermesConf := config.HermesConf() + hermesConf.WebProvider = "searxng" + hermesConf.WebURL = "http://localhost:8888" + if err := config.UpdateHermesConfig(hermesConf); err != nil { + t.Fatalf("failed to update Hermes config: %v", err) + } + + containerConfig, hostConfig, err := hermesContainerConf(HermesContainerName) + if err != nil { + t.Fatalf("expected Hermes container config, got error: %v", err) + } + + if got, ok := envValue(containerConfig.Env, "HERMES_WEB_SEARCH_BACKEND"); !ok || got != "searxng" { + t.Fatalf("HERMES_WEB_SEARCH_BACKEND = %q, %t; want searxng, true", got, ok) + } + if got, ok := envValue(containerConfig.Env, "SEARXNG_URL"); !ok || got != "http://host.docker.internal:8888" { + t.Fatalf("SEARXNG_URL = %q, %t; want host gateway URL, true", got, ok) + } + if _, ok := envValue(containerConfig.Env, "BRAVE_SEARCH_API_KEY"); ok { + t.Fatalf("SearXNG should not set a web API key env") + } + if len(hostConfig.ExtraHosts) != 1 || hostConfig.ExtraHosts[0] != "host.docker.internal:host-gateway" { + t.Fatalf("expected host-gateway extra host for local SearXNG, got %#v", hostConfig.ExtraHosts) + } +} + +func setupHermesContainerConfTest(t *testing.T, apiEnabled bool, apiKey string) { + t.Helper() + oldBasePath := config.BasePath + oldUrbits := config.UrbitsConfig + t.Cleanup(func() { + config.BasePath = oldBasePath + config.UrbitsConfig = oldUrbits + }) + + config.BasePath = t.TempDir() + config.UrbitsConfig = make(map[string]structs.UrbitDocker) + + pier := "sampel-palnet" + pierConf := structs.UrbitDocker{ + PierName: pier, + Network: "bridge", + HTTPPort: 8080, + } + pierPath := filepath.Join(config.BasePath, "settings", "pier", pier+".json") + if err := os.MkdirAll(filepath.Dir(pierPath), 0o755); err != nil { + t.Fatalf("failed to create pier config dir: %v", err) + } + pierJSON, err := json.Marshal(pierConf) + if err != nil { + t.Fatalf("failed to encode pier config: %v", err) + } + if err := os.WriteFile(pierPath, pierJSON, 0o644); err != nil { + t.Fatalf("failed to write pier config: %v", err) + } + + hermesConf := structs.HermesConfig{ + Enabled: true, + Ship: "~" + pier, + Owner: "~zod", + Port: DefaultHermesDashboardHostPort, + ModelProvider: DefaultHermesModelProvider, + Model: DefaultHermesModel, + ProviderAPIKey: "provider-api-key", + APIEnabled: apiEnabled, + APIKey: apiKey, + AccessCode: "access-code", + } + if err := config.UpdateHermesConfig(hermesConf); err != nil { + t.Fatalf("failed to write Hermes config: %v", err) + } +} + +func envValue(env []string, key string) (string, bool) { + prefix := key + "=" + for _, item := range env { + if after, ok := strings.CutPrefix(item, prefix); ok { + return after, true + } + } + return "", false +} diff --git a/goseg/docker/llama.go b/goseg/docker/llama.go deleted file mode 100644 index a716d41d4..000000000 --- a/goseg/docker/llama.go +++ /dev/null @@ -1,140 +0,0 @@ -package docker - -import ( - "fmt" - "groundseg/config" - "groundseg/defaults" - "groundseg/structs" - "os" - - "path/filepath" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/mount" - "github.com/docker/go-connections/nat" - "go.uber.org/zap" -) - -func LoadLlama() error { - conf := config.Conf() - if !conf.PenpaiAllow { - zap.L().Info("Llama GPT disabled") - return nil - } - zap.L().Info("Loading Llama GPT") - if !conf.PenpaiRunning { - if err := StopContainerByName("llama-gpt-api"); err != nil { - zap.L().Warn(fmt.Sprintf("Failed to kill Llama API: %v", err)) - } - } - info, err := StartContainer("llama-gpt-api", "llama-api") - if err != nil { - return fmt.Errorf("Error starting Llama API: %v", err) - } - config.UpdateContainerState("llama-api", info) - return nil -} - -func llamaApiContainerConf() (container.Config, container.HostConfig, error) { - conf := config.Conf() - var containerConfig container.Config - var hostConfig container.HostConfig - apiContainerName := "llama-gpt-api" - desiredImage := "nativeplanet/llama-gpt:dev@sha256:ac2dcfac72bc3d8ee51ee255edecc10072ef9c0f958120971c00be5f4944a6fa" - // lessCores := conf.PenpaiCores - exists, err := volumeExists(apiContainerName) - if err != nil { - return containerConfig, hostConfig, fmt.Errorf("Error checking volume: %v", err) - } - if !exists { - if err = CreateVolume(apiContainerName); err != nil { - return containerConfig, hostConfig, fmt.Errorf("Error creating volume: %v", err) - } - } - exists, err = volumeExists(apiContainerName + "_api") - if err != nil { - return containerConfig, hostConfig, fmt.Errorf("Error checking volume: %v", err) - } - if !exists { - if err = CreateVolume(apiContainerName + "_api"); err != nil { - return containerConfig, hostConfig, fmt.Errorf("Error creating volume: %v", err) - } - } - llamaNet, err := addOrGetNetwork("llama") - if err != nil { - return containerConfig, hostConfig, fmt.Errorf("Unable to create or get network: %v", err) - } - scriptPath := filepath.Join(config.DockerDir, apiContainerName+"_api", "_data", "run.sh") - if err := os.WriteFile(scriptPath, []byte(defaults.RunLlama), 0755); err != nil { - return containerConfig, hostConfig, fmt.Errorf("Failed to write script: %v", err) - } - var found *structs.Penpai - for _, item := range conf.PenpaiModels { - if item.ModelName == conf.PenpaiActive { - found = &item - break - } - } - containerConfig = container.Config{ - Image: desiredImage, - Hostname: apiContainerName, - Cmd: []string{"/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"}, - Env: []string{ - fmt.Sprintf("MODEL=/models/%v", found.ModelName), - fmt.Sprintf("MODEL_NAME=%v", found.ModelName), - fmt.Sprintf("MODEL_DOWNLOAD_URL=%v", found.ModelUrl), - "N_GQA=1", - "USE_MLOCK=1", - }, - ExposedPorts: nat.PortSet{ - "8000/tcp": struct{}{}, - }, - } - var piers []string - for _, pier := range conf.Piers { - if config.UrbitsConfig[pier].BootStatus == "boot" { - piers = append(piers, pier) - } - } - var binds []string - for _, pier := range piers { - hostPath := VolumeDir + "/" + pier + "/_data/" + pier + "/.urb/dev" - volPath := "/piers/" + pier - pierBind := hostPath + ":" + volPath - binds = append(binds, pierBind) - } - hostConfig = container.HostConfig{ - NetworkMode: container.NetworkMode(llamaNet), - RestartPolicy: container.RestartPolicy{ - Name: "on-failure", - }, - // Resources: container.Resources{ - // NanoCPUs: int64(lessCores) * 1e9, - // }, - PortBindings: nat.PortMap{ - "8000/tcp": []nat.PortBinding{ - { - HostIP: "0.0.0.0", - HostPort: "3001", - }, - }, - }, - Mounts: []mount.Mount{ - { - Type: mount.TypeVolume, - Source: apiContainerName, // host dir - Target: "/models", // in the container - }, - { - Type: mount.TypeVolume, - Source: apiContainerName + "_api", - Target: "/api", - }, - }, - Binds: binds, - CapAdd: []string{ - "IPC_LOCK", - }, - } - return containerConfig, hostConfig, nil -} diff --git a/goseg/docker/minio.go b/goseg/docker/minio.go index 67ecd4b60..c30f60eb0 100644 --- a/goseg/docker/minio.go +++ b/goseg/docker/minio.go @@ -228,6 +228,10 @@ func objectStoreCustomDomainForMode(shipConf structs.UrbitDocker, mode string) s return domain } } + if normalizedObjectStoreCustomDomain(shipConf.CustomS3WebLocal) != "" || + normalizedObjectStoreCustomDomain(shipConf.CustomS3WebRemote) != "" { + return "" + } return normalizedObjectStoreCustomDomain(shipConf.CustomS3Web) } @@ -260,6 +264,17 @@ func SetObjectStoreCustomDomain(conf structs.SysConfig, shipConf *structs.UrbitD structs.SyncCustomS3Domains(shipConf) } +func ClearObjectStoreCustomDomain(conf structs.SysConfig, shipConf *structs.UrbitDocker) { + switch ObjectStoreCustomDomainMode(conf, *shipConf) { + case "remote": + shipConf.CustomS3WebRemote = "" + default: + shipConf.CustomS3WebLocal = "" + } + shipConf.CustomS3Web = "" + structs.SyncCustomS3Domains(shipConf) +} + func objectStoreOfflineHostPorts(shipConf structs.UrbitDocker) (int, int) { consolePort := shipConf.HTTPPort + offlineRustFSConsoleOffset s3Port := shipConf.HTTPPort + offlineRustFSS3Offset diff --git a/goseg/docker/minio_test.go b/goseg/docker/minio_test.go index 9f7da21b2..c4830d354 100644 --- a/goseg/docker/minio_test.go +++ b/goseg/docker/minio_test.go @@ -190,6 +190,31 @@ func TestSetObjectStoreCustomDomainPreservesLegacyLocalCompatibility(t *testing. } } +func TestClearObjectStoreCustomDomainDoesNotRestoreFromLegacyFallback(t *testing.T) { + conf := structs.SysConfig{WgRegistered: true, WgOn: false} + shipConf := structs.UrbitDocker{ + HTTPPort: 8080, + CustomS3Web: "local.storage.example.com", + CustomS3WebLocal: "local.storage.example.com", + CustomS3WebRemote: "remote.storage.example.com", + } + + ClearObjectStoreCustomDomain(conf, &shipConf) + + if shipConf.CustomS3WebLocal != "" { + t.Fatalf("expected local custom domain to be cleared, got %q", shipConf.CustomS3WebLocal) + } + if shipConf.CustomS3WebRemote != "remote.storage.example.com" { + t.Fatalf("expected remote custom domain to remain, got %q", shipConf.CustomS3WebRemote) + } + if shipConf.CustomS3Web != "remote.storage.example.com" { + t.Fatalf("expected legacy compatibility field to move to remaining remote domain, got %q", shipConf.CustomS3Web) + } + if domain := ObjectStoreCustomDomain(conf, shipConf); domain != "" { + t.Fatalf("expected local mode to have no custom domain after removal, got %q", domain) + } +} + func TestObjectStoreCustomDomainIgnoresNullString(t *testing.T) { conf := structs.SysConfig{WgRegistered: true, WgOn: false} shipConf := structs.UrbitDocker{ diff --git a/goseg/docker/tags.go b/goseg/docker/tags.go new file mode 100644 index 000000000..7e9801c46 --- /dev/null +++ b/goseg/docker/tags.go @@ -0,0 +1,121 @@ +package docker + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "sync" + "time" +) + +const ( + dockerHubTagCacheTTL = 30 * time.Minute + dockerHubTagErrorCacheTTL = 30 * time.Second +) + +var ( + vereTagsMu sync.Mutex + vereTagsCache []string + vereTagsCacheErr error + vereTagsCacheAt time.Time +) + +type dockerHubTagsResponse struct { + Next string `json:"next"` + Results []struct { + Name string `json:"name"` + } `json:"results"` +} + +func GetVereImageTags() ([]string, error) { + vereTagsMu.Lock() + defer vereTagsMu.Unlock() + cacheAge := time.Since(vereTagsCacheAt) + if vereTagsCacheErr == nil && cacheAge < dockerHubTagCacheTTL { + return append([]string{}, vereTagsCache...), nil + } + if vereTagsCacheErr != nil && cacheAge < dockerHubTagErrorCacheTTL { + return append([]string{}, vereTagsCache...), vereTagsCacheErr + } + info, err := GetLatestContainerInfo("vere") + if err != nil { + vereTagsCacheErr = err + vereTagsCacheAt = time.Now() + return nil, err + } + tags, err := DockerHubTags(info["repo"]) + if err == nil { + tags = appendUnique(tags, info["tag"]) + sort.Strings(tags) + vereTagsCache = append([]string{}, tags...) + } else if len(vereTagsCache) > 0 { + tags = append([]string{}, vereTagsCache...) + } + vereTagsCacheErr = err + vereTagsCacheAt = time.Now() + return tags, err +} + +func DockerHubTags(repo string) ([]string, error) { + path := dockerHubRepoPath(repo) + if path == "" { + return nil, fmt.Errorf("unsupported Docker Hub repo %q", repo) + } + endpoint := fmt.Sprintf("https://registry.hub.docker.com/v2/repositories/%s/tags?page_size=100", path) + client := http.Client{Timeout: 15 * time.Second} + seen := map[string]bool{} + var tags []string + for endpoint != "" && len(tags) < 500 { + resp, err := client.Get(endpoint) + if err != nil { + return tags, err + } + if resp.StatusCode < 200 || resp.StatusCode > 299 { + statusCode := resp.StatusCode + resp.Body.Close() + return tags, fmt.Errorf("Docker Hub tags request failed: HTTP %d", statusCode) + } + var payload dockerHubTagsResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + resp.Body.Close() + return tags, err + } + resp.Body.Close() + for _, result := range payload.Results { + tag := strings.TrimSpace(result.Name) + if tag != "" && !seen[tag] { + seen[tag] = true + tags = append(tags, tag) + } + } + endpoint = payload.Next + } + return tags, nil +} + +func dockerHubRepoPath(repo string) string { + repo = strings.TrimSpace(repo) + repo = strings.TrimPrefix(repo, "https://") + repo = strings.TrimPrefix(repo, "http://") + for _, prefix := range []string{ + "registry.hub.docker.com/", + "docker.io/", + "index.docker.io/", + } { + repo = strings.TrimPrefix(repo, prefix) + } + if at := strings.Index(repo, "@"); at >= 0 { + repo = repo[:at] + } + if lastSlash, lastColon := strings.LastIndex(repo, "/"), strings.LastIndex(repo, ":"); lastColon > lastSlash { + repo = repo[:lastColon] + } + repo = strings.TrimPrefix(repo, "library/") + if strings.Count(repo, "/") != 1 { + return "" + } + return url.PathEscape(strings.Split(repo, "/")[0]) + "/" + url.PathEscape(strings.Split(repo, "/")[1]) +} diff --git a/goseg/docker/urbit.go b/goseg/docker/urbit.go index 23ec638b9..bac440061 100644 --- a/goseg/docker/urbit.go +++ b/goseg/docker/urbit.go @@ -8,6 +8,7 @@ import ( "groundseg/defaults" "groundseg/structs" "os" + "strings" "path/filepath" @@ -68,16 +69,26 @@ func urbitContainerConf(containerName string) (container.Config, container.HostC // sorry this is ugly shipConf := config.UrbitConf(containerName) newConf := shipConf - if config.Architecture == "amd64" { - if containerInfo["hash"] != shipConf.UrbitAmd64Sha256 { - newConf.UrbitAmd64Sha256 = containerInfo["hash"] - } - } else if config.Architecture == "arm64" { - if containerInfo["hash"] != shipConf.UrbitArm64Sha256 { - newConf.UrbitArm64Sha256 = containerInfo["hash"] + overrideTag := strings.TrimSpace(shipConf.UrbitImageTagOverride) + effectiveTag := containerInfo["tag"] + effectiveHash := containerInfo["hash"] + if overrideTag != "" { + effectiveTag = overrideTag + effectiveHash = "" + newConf.UrbitAmd64Sha256 = "" + newConf.UrbitArm64Sha256 = "" + } else { + if config.Architecture == "amd64" { + if containerInfo["hash"] != shipConf.UrbitAmd64Sha256 { + newConf.UrbitAmd64Sha256 = containerInfo["hash"] + } + } else if config.Architecture == "arm64" { + if containerInfo["hash"] != shipConf.UrbitArm64Sha256 { + newConf.UrbitArm64Sha256 = containerInfo["hash"] + } } } - newConf.UrbitVersion = containerInfo["tag"] + newConf.UrbitVersion = effectiveTag newConf.UrbitRepo = containerInfo["repo"] newConf.MinioVersion = objectStoreTag newConf.MinioRepo = objectStoreRepo @@ -86,7 +97,10 @@ func urbitContainerConf(containerName string) (container.Config, container.HostC zap.L().Error(fmt.Sprintf("Couldn't persist updated urbit conf! %v", err)) } } - desiredImage := fmt.Sprintf("%s:%s@sha256:%s", containerInfo["repo"], containerInfo["tag"], containerInfo["hash"]) + desiredImage := fmt.Sprintf("%s:%s", containerInfo["repo"], effectiveTag) + if effectiveHash != "" { + desiredImage = fmt.Sprintf("%s@sha256:%s", desiredImage, effectiveHash) + } // reload urbit conf from disk err = config.LoadUrbitConfig(containerName) if err != nil { diff --git a/goseg/handler/config_files.go b/goseg/handler/config_files.go index 4b7b75c80..7507809ec 100644 --- a/goseg/handler/config_files.go +++ b/goseg/handler/config_files.go @@ -5,15 +5,18 @@ import ( "fmt" "groundseg/auth" "groundseg/config" + "groundseg/docker" "groundseg/structs" "net/http" "os" "path" "path/filepath" + "regexp" "slices" "strings" "go.uber.org/zap" + "gopkg.in/yaml.v3" ) type configFileRequest struct { @@ -45,6 +48,8 @@ type configFileTarget struct { path string } +var hermesEnvLinePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*=`) + func ConfigFilesHandler(w http.ResponseWriter, r *http.Request) { setConfigFilesCORS(w) if r.Method == http.MethodOptions { @@ -73,7 +78,15 @@ func ConfigFilesHandler(w http.ResponseWriter, r *http.Request) { writeConfigFilesError(w, http.StatusBadRequest, err) return } - content, err := os.ReadFile(target.path) + var content []byte + switch target.kind { + case "hermes-yaml": + content, err = docker.ReadFileFromVolume(docker.HermesDataVolumeName, "config.yaml") + case "hermes-env": + content, err = docker.ReadFileFromVolume(docker.HermesDataVolumeName, ".env") + default: + content, err = os.ReadFile(target.path) + } if err != nil { writeConfigFilesError(w, http.StatusInternalServerError, fmt.Errorf("unable to read %s: %v", target.file, err)) return @@ -95,6 +108,19 @@ func ConfigFilesHandler(w http.ResponseWriter, r *http.Request) { formatted, err = config.ReplaceConfJSON([]byte(req.Content)) case "pier": formatted, err = config.ReplaceUrbitConfigJSON(target.pier, []byte(req.Content)) + case "hermes-yaml": + formatted, err = validateHermesConfigYAML([]byte(req.Content)) + if err == nil { + err = docker.WriteFileToVolume(docker.HermesDataVolumeName, "config.yaml", string(formatted)) + } + case "hermes-env": + formatted, err = validateHermesEnv([]byte(req.Content)) + if err == nil { + err = docker.WriteFileToVolume(docker.HermesDataVolumeName, ".env", string(formatted)) + } + if err == nil { + err = docker.WriteFileToVolume(docker.HermesWorkspaceVolumeName, ".env", string(formatted)) + } default: err = fmt.Errorf("unsupported config kind %q", target.kind) } @@ -110,11 +136,23 @@ func ConfigFilesHandler(w http.ResponseWriter, r *http.Request) { func listConfigFiles() []configFileSummary { conf := config.Conf() - files := []configFileSummary{{ - File: "system.json", - Label: "System settings", - Kind: "system", - }} + files := []configFileSummary{ + { + File: "system.json", + Label: "System settings", + Kind: "system", + }, + { + File: "hermes/config.yaml", + Label: "Hermes config.yaml", + Kind: "hermes-yaml", + }, + { + File: "hermes/.env", + Label: "Hermes .env", + Kind: "hermes-env", + }, + } for _, pier := range conf.Piers { files = append(files, configFileSummary{ File: fmt.Sprintf("pier/%s.json", pier), @@ -142,6 +180,16 @@ func resolveConfigFileTarget(file string) (configFileTarget, error) { kind: "system", path: filepath.Join(config.BasePath, "settings", "system.json"), }, nil + case "hermes/config.yaml": + return configFileTarget{ + file: "hermes/config.yaml", + kind: "hermes-yaml", + }, nil + case "hermes/.env": + return configFileTarget{ + file: "hermes/.env", + kind: "hermes-env", + }, nil } if path.Dir(cleaned) != "pier" || path.Ext(cleaned) != ".json" { return configFileTarget{}, fmt.Errorf("unsupported config file: %s", file) @@ -161,6 +209,42 @@ func resolveConfigFileTarget(file string) (configFileTarget, error) { }, nil } +func validateHermesConfigYAML(raw []byte) ([]byte, error) { + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" { + return nil, fmt.Errorf("Hermes config.yaml cannot be empty") + } + var decoded any + if err := yaml.Unmarshal([]byte(trimmed), &decoded); err != nil { + return nil, fmt.Errorf("invalid Hermes config.yaml: %v", err) + } + if _, ok := decoded.(map[string]any); !ok { + return nil, fmt.Errorf("Hermes config.yaml must be a YAML mapping") + } + return []byte(trimmed + "\n"), nil +} + +func validateHermesEnv(raw []byte) ([]byte, error) { + if strings.ContainsRune(string(raw), '\x00') { + return nil, fmt.Errorf("Hermes .env cannot contain null bytes") + } + trimmed := strings.TrimSpace(string(raw)) + if trimmed == "" { + return nil, fmt.Errorf("Hermes .env cannot be empty") + } + for idx, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(strings.TrimSuffix(line, "\r")) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + if !hermesEnvLinePattern.MatchString(line) { + return nil, fmt.Errorf("invalid Hermes .env line %d: expected NAME=value", idx+1) + } + } + return []byte(strings.TrimRight(string(raw), "\r\n") + "\n"), nil +} + func configuredPier(pier string) bool { return slices.Contains(config.Conf().Piers, pier) } diff --git a/goseg/handler/config_files_test.go b/goseg/handler/config_files_test.go index 3277e5974..2830bd75b 100644 --- a/goseg/handler/config_files_test.go +++ b/goseg/handler/config_files_test.go @@ -43,6 +43,8 @@ func TestResolveConfigFileTarget(t *testing.T) { {name: "system file", file: "system.json", kind: "system"}, {name: "settings alias", file: "settings.json", kind: "system"}, {name: "configured pier", file: "pier/sampel-palnet.json", kind: "pier", pier: "sampel-palnet"}, + {name: "hermes yaml", file: "hermes/config.yaml", kind: "hermes-yaml"}, + {name: "hermes env", file: "hermes/.env", kind: "hermes-env"}, {name: "traversal", file: "../system.json", wantErr: true}, {name: "nested traversal", file: "pier/../system.json", wantErr: true}, {name: "nested pier path", file: "pier/sampel-palnet/extra.json", wantErr: true}, @@ -70,3 +72,66 @@ func TestResolveConfigFileTarget(t *testing.T) { }) } } + +func TestValidateHermesEnv(t *testing.T) { + tests := []struct { + name string + raw string + wantErr bool + }{ + {name: "basic", raw: "OPENAI_BASE_URL=http://localhost:1234/v1\nOPENAI_API_KEY=\n"}, + {name: "export", raw: "export HERMES_MODEL=local-model\n"}, + {name: "comments", raw: "# local endpoint\nOPENAI_API_KEY=sk-test\n"}, + {name: "empty", raw: " \n", wantErr: true}, + {name: "invalid key", raw: "1BAD=value\n", wantErr: true}, + {name: "missing separator", raw: "OPENAI_API_KEY\n", wantErr: true}, + {name: "null byte", raw: "OPENAI_API_KEY=x\x00\n", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := validateHermesEnv([]byte(tt.raw)) + if tt.wantErr { + if err == nil { + t.Fatalf("validateHermesEnv(%q) returned nil error", tt.raw) + } + return + } + if err != nil { + t.Fatalf("validateHermesEnv(%q) returned error: %v", tt.raw, err) + } + if len(got) == 0 || got[len(got)-1] != '\n' { + t.Fatalf("validated env should end with newline: %q", string(got)) + } + }) + } +} + +func TestValidateHermesConfigYAML(t *testing.T) { + tests := []struct { + name string + raw string + wantErr bool + }{ + {name: "mapping", raw: "model:\n provider: openrouter\n"}, + {name: "empty", raw: " \n", wantErr: true}, + {name: "invalid", raw: "model: [", wantErr: true}, + {name: "scalar", raw: "hello", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := validateHermesConfigYAML([]byte(tt.raw)) + if tt.wantErr { + if err == nil { + t.Fatalf("validateHermesConfigYAML(%q) returned nil error", tt.raw) + } + return + } + if err != nil { + t.Fatalf("validateHermesConfigYAML(%q) returned error: %v", tt.raw, err) + } + if len(got) == 0 || got[len(got)-1] != '\n' { + t.Fatalf("validated YAML should end with newline: %q", string(got)) + } + }) + } +} diff --git a/goseg/handler/hermes.go b/goseg/handler/hermes.go new file mode 100644 index 000000000..c177797ec --- /dev/null +++ b/goseg/handler/hermes.go @@ -0,0 +1,531 @@ +package handler + +import ( + "encoding/json" + "fmt" + "groundseg/click" + "groundseg/config" + "groundseg/docker" + "groundseg/structs" + "net" + "slices" + "strings" + "time" + + "go.uber.org/zap" +) + +func HermesHandler(msg []byte) error { + var hermesPayload structs.WsHermesPayload + if err := json.Unmarshal(msg, &hermesPayload); err != nil { + return fmt.Errorf("couldn't unmarshal Hermes payload: %v", err) + } + switch hermesPayload.Payload.Action { + case "install": + go handleHermesInstall(hermesPayload) + case "update": + go handleHermesUpdate(hermesPayload) + case "toggle": + go handleHermesToggle(hermesPayload) + case "save": + go handleHermesSave(hermesPayload) + case "restart": + go handleHermesRestart() + default: + return fmt.Errorf("unrecognized Hermes action: %v", hermesPayload.Payload.Action) + } + return nil +} + +func handleHermesInstall(hermesPayload structs.WsHermesPayload) { + clearHermesError() + docker.HermesTransBus <- structs.Event{Type: "install", Data: "preparing"} + defer clearHermesTransition("install") + if err := config.LoadHermesConfig(); err != nil { + failHermesTransition("install", err) + return + } + hermesConf := config.HermesConf() + if err := applyHermesPayload(hermesPayload.Payload, &hermesConf); err != nil { + failHermesTransition("install", err) + return + } + if err := config.UpdateHermesConfig(hermesConf); err != nil { + failHermesTransition("install", err) + return + } + image := docker.HermesImageOrDefault(hermesConf.Image) + zap.L().Info(fmt.Sprintf("Installing Hermes image %s", image)) + if err := docker.PullImageByRefWithProgress(image, func(status string) { + docker.HermesTransBus <- structs.Event{Type: "install", Data: status} + }); err != nil { + failHermesTransition("install", err) + return + } + zap.L().Info(fmt.Sprintf("Hermes image %s installed", image)) + docker.HermesTransBus <- structs.Event{Type: "install", Data: "success"} +} + +func handleHermesUpdate(hermesPayload structs.WsHermesPayload) { + clearHermesError() + docker.HermesTransBus <- structs.Event{Type: "install", Data: "preparing"} + defer clearHermesTransition("install") + if err := config.LoadHermesConfig(); err != nil { + failHermesTransition("install", err) + return + } + hermesConf := config.HermesConf() + wasEnabled := hermesConf.Enabled + if err := applyHermesPayload(hermesPayload.Payload, &hermesConf); err != nil { + failHermesTransition("install", err) + return + } + versionServerImage, err := docker.HermesVersionServerImage() + if err != nil { + failHermesTransition("install", err) + return + } + hermesConf.Image = versionServerImage + docker.HermesTransBus <- structs.Event{Type: "install", Data: "removing-container"} + stopAndDeleteHermes(false) + zap.L().Info(fmt.Sprintf("Updating Hermes image to %s", versionServerImage)) + if err := docker.PullImageByRefWithProgress(versionServerImage, func(status string) { + docker.HermesTransBus <- structs.Event{Type: "install", Data: status} + }); err != nil { + failHermesTransition("install", err) + return + } + if wasEnabled { + docker.HermesTransBus <- structs.Event{Type: "install", Data: "validating"} + if err := validateRunnableHermes(hermesConf); err != nil { + failHermesTransition("install", err) + return + } + docker.HermesTransBus <- structs.Event{Type: "install", Data: "fetching-code"} + if err := refreshHermesAccessCode(&hermesConf); err != nil { + failHermesTransition("install", err) + return + } + } + if err := config.UpdateHermesConfig(hermesConf); err != nil { + failHermesTransition("install", err) + return + } + if wasEnabled { + docker.HermesTransBus <- structs.Event{Type: "install", Data: "starting"} + if err := recreateHermesContainer(); err != nil { + failHermesTransition("install", err) + return + } + } + zap.L().Info(fmt.Sprintf("Hermes image %s updated", versionServerImage)) + docker.HermesTransBus <- structs.Event{Type: "install", Data: "success"} +} + +func handleHermesToggle(hermesPayload structs.WsHermesPayload) { + clearHermesError() + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "loading"} + defer clearHermesTransition("toggle") + if err := config.LoadHermesConfig(); err != nil { + failHermesTransition("toggle", err) + return + } + hermesConf := config.HermesConf() + if hermesConf.Enabled { + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "stopping"} + hermesConf.Enabled = false + hermesConf.AccessCode = "" + if err := config.UpdateHermesConfig(hermesConf); err != nil { + failHermesTransition("toggle", err) + return + } + stopAndDeleteHermes(false) + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "success"} + return + } + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "validating"} + if err := applyHermesPayload(hermesPayload.Payload, &hermesConf); err != nil { + failHermesTransition("toggle", err) + return + } + if err := validateRunnableHermes(hermesConf); err != nil { + failHermesTransition("toggle", err) + return + } + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "fetching-code"} + if err := refreshHermesAccessCode(&hermesConf); err != nil { + failHermesTransition("toggle", err) + return + } + hermesConf.Enabled = true + if err := config.UpdateHermesConfig(hermesConf); err != nil { + failHermesTransition("toggle", err) + return + } + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "starting"} + if err := recreateHermesContainer(); err != nil { + failHermesTransition("toggle", err) + return + } + docker.HermesTransBus <- structs.Event{Type: "toggle", Data: "success"} +} + +func handleHermesSave(hermesPayload structs.WsHermesPayload) { + clearHermesError() + docker.HermesTransBus <- structs.Event{Type: "save", Data: "saving"} + defer clearHermesTransition("save") + if err := config.LoadHermesConfig(); err != nil { + failHermesTransition("save", err) + return + } + hermesConf := config.HermesConf() + if err := applyHermesPayload(hermesPayload.Payload, &hermesConf); err != nil { + failHermesTransition("save", err) + return + } + if hermesConf.Enabled { + docker.HermesTransBus <- structs.Event{Type: "save", Data: "validating"} + if err := validateRunnableHermes(hermesConf); err != nil { + failHermesTransition("save", err) + return + } + docker.HermesTransBus <- structs.Event{Type: "save", Data: "fetching-code"} + if err := refreshHermesAccessCode(&hermesConf); err != nil { + failHermesTransition("save", err) + return + } + } else { + hermesConf.AccessCode = "" + } + if err := config.UpdateHermesConfig(hermesConf); err != nil { + failHermesTransition("save", err) + return + } + if hermesConf.Enabled { + docker.HermesTransBus <- structs.Event{Type: "save", Data: "restarting"} + if err := recreateHermesContainer(); err != nil { + failHermesTransition("save", err) + return + } + } + docker.HermesTransBus <- structs.Event{Type: "save", Data: "success"} +} + +func handleHermesRestart() { + clearHermesError() + docker.HermesTransBus <- structs.Event{Type: "restart", Data: "validating"} + defer clearHermesTransition("restart") + if err := config.LoadHermesConfig(); err != nil { + failHermesTransition("restart", err) + return + } + hermesConf := config.HermesConf() + if !hermesConf.Enabled { + failHermesTransition("restart", fmt.Errorf("Hermes is not enabled")) + return + } + if err := validateRunnableHermes(hermesConf); err != nil { + failHermesTransition("restart", err) + return + } + docker.HermesTransBus <- structs.Event{Type: "restart", Data: "fetching-code"} + if err := refreshHermesAccessCode(&hermesConf); err != nil { + failHermesTransition("restart", err) + return + } + if err := config.UpdateHermesConfig(hermesConf); err != nil { + failHermesTransition("restart", err) + return + } + docker.HermesTransBus <- structs.Event{Type: "restart", Data: "recreating"} + if err := recreateHermesContainer(); err != nil { + failHermesTransition("restart", err) + return + } + docker.HermesTransBus <- structs.Event{Type: "restart", Data: "success"} +} + +func applyHermesPayload(payload structs.WsHermesAction, hermesConf *structs.HermesConfig) error { + if ship := docker.NormalizeHermesShip(payload.Ship); ship != "" { + hermesConf.Ship = ship + } + if owner := docker.NormalizeHermesShip(payload.Owner); owner != "" { + hermesConf.Owner = owner + } + if payload.Port > 0 { + if payload.Port > 65535 { + return fmt.Errorf("invalid Hermes port %d", payload.Port) + } + hermesConf.Port = payload.Port + } + if hermesConf.Port <= 0 { + port, err := nextHermesPort() + if err != nil { + return err + } + hermesConf.Port = port + } + if image := strings.TrimSpace(payload.Image); image != "" { + if !isPinnedImageRef(image) { + return fmt.Errorf("Hermes image must be pinned by non-latest tag or sha256 digest") + } + hermesConf.Image = image + } + if strings.TrimSpace(hermesConf.Image) == "" { + hermesConf.Image = docker.HermesImageOrDefault("") + } + if !isPinnedImageRef(hermesConf.Image) { + return fmt.Errorf("Hermes image must be pinned by non-latest tag or sha256 digest") + } + if provider := strings.TrimSpace(payload.ModelProvider); provider != "" { + normalizedProvider := docker.NormalizeHermesModelProvider(provider) + if normalizedProvider == "" { + return fmt.Errorf("unsupported Hermes provider %q", provider) + } + if normalizedProvider != hermesConf.ModelProvider && strings.TrimSpace(payload.ProviderAPIKey) == "" { + hermesConf.ProviderAPIKey = "" + } + hermesConf.ModelProvider = normalizedProvider + } + if model := strings.TrimSpace(payload.Model); model != "" { + hermesConf.Model = model + } + if providerAPIKey := strings.TrimSpace(payload.ProviderAPIKey); providerAPIKey != "" { + hermesConf.ProviderAPIKey = providerAPIKey + } + if webProvider := strings.TrimSpace(payload.WebProvider); webProvider != "" { + normalizedWebProvider := docker.NormalizeHermesWebProvider(webProvider) + if normalizedWebProvider == "" { + return fmt.Errorf("unsupported Hermes web provider %q", webProvider) + } + if normalizedWebProvider != hermesConf.WebProvider && strings.TrimSpace(payload.WebAPIKey) == "" { + hermesConf.WebAPIKey = "" + } + if normalizedWebProvider != hermesConf.WebProvider && strings.TrimSpace(payload.WebURL) == "" { + hermesConf.WebURL = "" + } + hermesConf.WebProvider = normalizedWebProvider + } else if payload.Action == "save" || payload.Action == "toggle" || payload.Action == "install" || payload.Action == "update" { + hermesConf.WebProvider = "" + hermesConf.WebAPIKey = "" + hermesConf.WebURL = "" + } + if webAPIKey := strings.TrimSpace(payload.WebAPIKey); webAPIKey != "" { + hermesConf.WebAPIKey = webAPIKey + } + if webURL := strings.TrimSpace(payload.WebURL); webURL != "" { + hermesConf.WebURL = webURL + } + if webProvider, ok := docker.HermesWebProviderConfig(hermesConf.WebProvider); ok { + if webProvider.APIKeyEnv == "" { + hermesConf.WebAPIKey = "" + } + if webProvider.URLEnv == "" { + hermesConf.WebURL = "" + } + } + if payload.Action == "save" || payload.Action == "toggle" || payload.Action == "install" { + hermesConf.APIEnabled = payload.APIEnabled + } + if apiKey := strings.TrimSpace(payload.APIKey); apiKey != "" { + hermesConf.APIKey = apiKey + } + if strings.TrimSpace(hermesConf.ModelProvider) == "" { + hermesConf.ModelProvider = docker.DefaultHermesModelProvider + } + if strings.TrimSpace(hermesConf.Model) == "" { + hermesConf.Model = docker.DefaultHermesModel + } + if strings.TrimSpace(hermesConf.HermesVersion) == "" { + hermesConf.HermesVersion = docker.DefaultHermesVersion + } + if strings.TrimSpace(hermesConf.HermesAgentRef) == "" { + hermesConf.HermesAgentRef = docker.DefaultHermesAgentRef + } + if strings.TrimSpace(hermesConf.TlonAdapterVersion) == "" { + hermesConf.TlonAdapterVersion = docker.DefaultHermesTlonAdapterVersion + } + if strings.TrimSpace(hermesConf.TlonAdapterRef) == "" { + hermesConf.TlonAdapterRef = docker.DefaultHermesTlonAdapterRef + } + if strings.TrimSpace(hermesConf.WebProvider) != "" && docker.NormalizeHermesWebProvider(hermesConf.WebProvider) == "" { + return fmt.Errorf("unsupported Hermes web provider %q", hermesConf.WebProvider) + } + return nil +} + +func validateRunnableHermes(hermesConf structs.HermesConfig) error { + ship := strings.TrimPrefix(docker.NormalizeHermesShip(hermesConf.Ship), "~") + if ship == "" { + return fmt.Errorf("Hermes ship is required") + } + if docker.NormalizeHermesShip(hermesConf.Owner) == "" { + return fmt.Errorf("Hermes owner is required") + } + if !pierExists(ship) { + return fmt.Errorf("Hermes ship %s is not managed by GroundSeg", docker.NormalizeHermesShip(ship)) + } + apiKeyEnv := docker.HermesProviderAPIKeyEnv(hermesConf.ModelProvider) + if apiKeyEnv == "" { + return fmt.Errorf("unsupported Hermes provider %q", hermesConf.ModelProvider) + } + if strings.TrimSpace(hermesConf.ProviderAPIKey) == "" { + return fmt.Errorf("Hermes provider API key is required for %s", docker.HermesModelProviderOrDefault(hermesConf.ModelProvider)) + } + if webProvider := docker.NormalizeHermesWebProvider(hermesConf.WebProvider); webProvider != "" { + webProviderConfig, ok := docker.HermesWebProviderConfig(webProvider) + if !ok { + return fmt.Errorf("unsupported Hermes web provider %q", hermesConf.WebProvider) + } + if webProviderConfig.APIKeyEnv != "" && strings.TrimSpace(hermesConf.WebAPIKey) == "" { + return fmt.Errorf("Hermes web API key is required for %s", webProvider) + } + if webProviderConfig.URLEnv != "" && strings.TrimSpace(hermesConf.WebURL) == "" { + return fmt.Errorf("Hermes web URL is required for %s", webProvider) + } + } + if hermesConf.APIEnabled && strings.TrimSpace(hermesConf.APIKey) == "" { + return fmt.Errorf("Hermes API key is required when the API server is enabled") + } + installed, err := docker.ImageRefExists(docker.HermesImageOrDefault(hermesConf.Image)) + if err != nil { + return fmt.Errorf("failed to inspect Hermes image: %v", err) + } + if !installed { + return fmt.Errorf("install the Hermes image before enabling Hermes") + } + return nil +} + +func pierExists(patp string) bool { + return slices.Contains(config.Conf().Piers, patp) +} + +func refreshHermesAccessCode(hermesConf *structs.HermesConfig) error { + patp := strings.TrimPrefix(docker.NormalizeHermesShip(hermesConf.Ship), "~") + statuses, err := docker.GetShipStatus([]string{patp}) + if err != nil { + return fmt.Errorf("failed to get ship status for Hermes %s: %v", patp, err) + } + status, exists := statuses[patp] + if !exists || !strings.Contains(status, "Up") { + return fmt.Errorf("ship %s must be running before Hermes can start", patp) + } + click.ClearLusCode(patp) + code, err := click.GetLusCode(patp) + if err != nil { + return fmt.Errorf("failed to fetch +code for Hermes %s: %v", patp, err) + } + zap.L().Info(fmt.Sprintf("Fetched fresh +code for Hermes %s", patp)) + hermesConf.AccessCode = code + return nil +} + +func recreateHermesContainer() error { + zap.L().Info("Recreating Hermes container") + stopAndDeleteHermes(false) + zap.L().Info("Starting Hermes container") + info, err := docker.StartContainer(docker.HermesContainerName, "hermes") + if err != nil { + return fmt.Errorf("couldn't start Hermes: %v", err) + } + config.UpdateContainerState(docker.HermesContainerName, info) + zap.L().Info("Hermes container started") + return nil +} + +func restartHermesForShipIfEnabled(patp string) { + if err := config.LoadHermesConfig(); err != nil { + zap.L().Warn(fmt.Sprintf("Unable to load Hermes config for ship restart check: %v", err)) + return + } + hermesConf := config.HermesConf() + if !hermesConf.Enabled || strings.TrimPrefix(docker.NormalizeHermesShip(hermesConf.Ship), "~") != patp { + return + } + go handleHermesRestart() +} + +func disableHermesIfAssignedTo(patp string) { + if err := config.LoadHermesConfig(); err != nil { + zap.L().Warn(fmt.Sprintf("Unable to load Hermes config for ship delete check: %v", err)) + return + } + hermesConf := config.HermesConf() + if strings.TrimPrefix(docker.NormalizeHermesShip(hermesConf.Ship), "~") != patp { + return + } + hermesConf.Enabled = false + hermesConf.AccessCode = "" + if err := config.UpdateHermesConfig(hermesConf); err != nil { + zap.L().Warn(fmt.Sprintf("Unable to disable Hermes for deleted ship %s: %v", patp, err)) + } + stopAndDeleteHermes(false) +} + +func stopAndDeleteHermes(deleteVolume bool) { + if existing, err := docker.FindContainer(docker.HermesContainerName); err == nil && existing != nil { + zap.L().Info("Stopping existing Hermes container") + if existing.State == "running" { + if err := docker.StopContainerByName(docker.HermesContainerName); err != nil { + zap.L().Warn(fmt.Sprintf("Couldn't stop Hermes container: %v", err)) + } + } + if err := docker.DeleteContainer(docker.HermesContainerName); err != nil { + zap.L().Warn(fmt.Sprintf("Couldn't delete Hermes container: %v", err)) + } + } + if deleteVolume { + if err := docker.DeleteVolume(docker.HermesDataVolumeName); err != nil { + zap.L().Warn(fmt.Sprintf("Couldn't delete Hermes volume: %v", err)) + } + if err := docker.DeleteVolume(docker.HermesWorkspaceVolumeName); err != nil { + zap.L().Warn(fmt.Sprintf("Couldn't delete Hermes workspace volume: %v", err)) + } + } + config.DeleteContainerState(docker.HermesContainerName) +} + +func nextHermesPort() (int, error) { + for port := docker.DefaultHermesDashboardHostPort; port <= 19999; port++ { + ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) + if err != nil { + continue + } + _ = ln.Close() + return port, nil + } + return 0, fmt.Errorf("no open Hermes dashboard port found") +} + +func isPinnedImageRef(ref string) bool { + ref = strings.TrimSpace(ref) + if ref == "" { + return false + } + if strings.Contains(ref, "@sha256:") { + return true + } + lastSlash := strings.LastIndex(ref, "/") + lastColon := strings.LastIndex(ref, ":") + if lastColon <= lastSlash { + return false + } + tag := strings.TrimSpace(ref[lastColon+1:]) + return tag != "" && tag != "latest" +} + +func failHermesTransition(kind string, err error) { + zap.L().Error(fmt.Sprintf("Hermes %s failed: %v", kind, err)) + docker.HermesTransBus <- structs.Event{Type: "error", Data: err.Error()} + docker.HermesTransBus <- structs.Event{Type: kind, Data: "error"} +} + +func clearHermesError() { + docker.HermesTransBus <- structs.Event{Type: "error", Data: nil} +} + +func clearHermesTransition(kind string) { + time.Sleep(2 * time.Second) + docker.HermesTransBus <- structs.Event{Type: kind, Data: nil} +} diff --git a/goseg/handler/leak.go b/goseg/handler/leak.go index f3817e85e..97865c236 100644 --- a/goseg/handler/leak.go +++ b/goseg/handler/leak.go @@ -49,10 +49,6 @@ func gallsegAuthedHandler(action leakchannel.ActionChannel) { if err := UrbitHandler(action.Content); err != nil { zap.L().Error(fmt.Sprintf("%+v", err)) } - case "penpai": - if err := PenpaiHandler(action.Content); err != nil { - zap.L().Error(fmt.Sprintf("%v", err)) - } case "new_ship": if err := NewShipHandler(action.Content); err != nil { zap.L().Error(fmt.Sprintf("%v", err)) diff --git a/goseg/handler/newship.go b/goseg/handler/newship.go index add889a3c..efe7c5a33 100644 --- a/goseg/handler/newship.go +++ b/goseg/handler/newship.go @@ -170,15 +170,6 @@ func createUrbitShip(patp string, shipPayload structs.WsNewShipPayload) { // Register Services go newShipRegisterService(patp) } - if conf.PenpaiAllow { - if err := docker.StopContainerByName("llama"); err != nil { - zap.L().Error(fmt.Sprintf("Couldn't stop Llama: %v", err)) - } - _, err = docker.StartContainer("llama", "llama") - if err != nil { - zap.L().Error(fmt.Sprintf("Couldn't restart Llama: %v", err)) - } - } // check for +code go waitForShipReady(shipPayload, customDrive) } @@ -241,10 +232,6 @@ func waitForShipReady(shipPayload structs.WsNewShipPayload, customDrive string) } startram.Retrieve() docker.NewShipTransBus <- structs.NewShipTransition{Type: "bootStage", Event: "completed"} - // restart llama if it's enabled to reload avail ships - if conf.PenpaiAllow { - docker.StartContainer("llama-gpt-api", "llama-api") - } return } } diff --git a/goseg/handler/penpai.go b/goseg/handler/penpai.go deleted file mode 100644 index ea9c7285c..000000000 --- a/goseg/handler/penpai.go +++ /dev/null @@ -1,98 +0,0 @@ -package handler - -import ( - "encoding/json" - "fmt" - "groundseg/config" - "groundseg/docker" - "groundseg/structs" - "runtime" - - "go.uber.org/zap" -) - -func PenpaiHandler(msg []byte) error { - zap.L().Info("Penpai") - var penpaiPayload structs.WsPenpaiPayload - err := json.Unmarshal(msg, &penpaiPayload) - if err != nil { - return fmt.Errorf("Couldn't unmarshal penpai payload: %v", err) - } - conf := config.Conf() - switch penpaiPayload.Payload.Action { - case "toggle": - running := false - if conf.PenpaiRunning { - // stop container - err := docker.StopContainerByName("llama-gpt-api") - if err != nil { - return fmt.Errorf("Failed to stop Llama API: %v", err) - } - err = docker.StopContainerByName("llama-gpt-ui") - if err != nil { - return fmt.Errorf("Failed to stop Llama UI: %v", err) - } - } else { - // start container - info, err := docker.StartContainer("llama-gpt-api", "llama-api") - if err != nil { - return fmt.Errorf("Error starting Llama API: %v", err) - } - config.UpdateContainerState("llama-api", info) - running = true - } - if err = config.UpdateConf(map[string]any{ - "penpaiRunning": running, - }); err != nil { - return fmt.Errorf("%v", err) - } - return nil - case "set-model": - // update config - model := penpaiPayload.Payload.Model - if err = config.UpdateConf(map[string]any{ - "penpaiActive": model, - }); err != nil { - return fmt.Errorf("%v", err) - } - if err := docker.DeleteContainer("llama-gpt-api"); err != nil { - return fmt.Errorf("Failed to delete container: %v", err) - } - // if running, restart container - if conf.PenpaiRunning { - if _, err := docker.StartContainer("llama-gpt-api", "llama-api"); err != nil { - return fmt.Errorf("Couldn't start Llama API: %v", err) - } - } - case "set-cores": - cores := penpaiPayload.Payload.Cores - // check if core count is valid - if cores < 1 { - return fmt.Errorf("Penpai unable to set 0 cores!") - } - if cores >= runtime.NumCPU() { - return fmt.Errorf("Penpai unable to set %v cores!", cores) - } - // update config - if err = config.UpdateConf(map[string]any{ - "penpaiCores": cores, - }); err != nil { - return fmt.Errorf("%v", err) - } - if err := docker.DeleteContainer("llama-gpt-api"); err != nil { - return fmt.Errorf("Failed to delete container: %v", err) - } - // if running, restart container - if conf.PenpaiRunning { - if _, err := docker.StartContainer("llama-gpt-api", "llama-api"); err != nil { - return fmt.Errorf("Couldn't start Llama API: %v", err) - } - } - return nil - case "remove": - // check if container exists - // remove container, delete volume - zap.L().Debug(fmt.Sprintf("Todo: remove penpai")) - } - return nil -} diff --git a/goseg/handler/startram.go b/goseg/handler/startram.go index 1640d3745..8494aaa73 100644 --- a/goseg/handler/startram.go +++ b/goseg/handler/startram.go @@ -133,6 +133,9 @@ func handleStartramRestart() { if err := docker.LoadObjectStores(); err != nil { zap.L().Error(fmt.Sprintf("Failed to load RustFS containers: %v", err)) } + if err := docker.LoadHermes(); err != nil { + zap.L().Error(fmt.Sprintf("Failed to load Hermes container: %v", err)) + } startram.EventBus <- structs.Event{Type: "restart", Data: "done"} showDone = true } diff --git a/goseg/handler/support.go b/goseg/handler/support.go index 1c45f3f0e..9d7c695c6 100644 --- a/goseg/handler/support.go +++ b/goseg/handler/support.go @@ -71,7 +71,6 @@ func SupportHandler(msg []byte) error { description := supportPayload.Payload.Description ships := supportPayload.Payload.Ships cpuProfile := supportPayload.Payload.CPUProfile - penpai := supportPayload.Payload.Penpai // set bug report dir bugReportDir := filepath.Join(bugReportPath, timestamp) @@ -82,7 +81,7 @@ func SupportHandler(msg []byte) error { } // write bug report to disk - if err := dumpBugReport(bugReportDir, timestamp, contact, description, ships, penpai); err != nil { + if err := dumpBugReport(bugReportDir, timestamp, contact, description, ships); err != nil { return handleError(fmt.Errorf("Failed to dump logs: %v", err)) } @@ -166,7 +165,7 @@ func dumpDockerLogs(containerID string, path string) error { return nil } -func dumpBugReport(bugReportDir, timestamp, contact, description string, piers []string, llama bool) error { +func dumpBugReport(bugReportDir, timestamp, contact, description string, piers []string) error { // description.txt descPath := filepath.Join(bugReportDir, "description.txt") @@ -175,13 +174,6 @@ func dumpBugReport(bugReportDir, timestamp, contact, description string, piers [ return err } - // llama bug dump - if llama { - if err := dumpDockerLogs("llama-gpt-api", bugReportDir+"/"+"llama.log"); err != nil { - zap.L().Warn(fmt.Sprintf("Couldn't dump llama logs: %v", err)) - } - } - // selected pier logs for _, pier := range piers { if err := dumpDockerLogs(pier, bugReportDir+"/"+pier+".log"); err != nil { @@ -197,6 +189,11 @@ func dumpBugReport(bugReportDir, timestamp, contact, description string, piers [ if err := dumpDockerLogs("wireguard", bugReportDir+"/wireguard.log"); err != nil { zap.L().Warn(fmt.Sprintf("Couldn't dump pier logs: %v", err)) } + if existing, err := docker.FindContainer(docker.HermesContainerName); err == nil && existing != nil { + if err := dumpDockerLogs(docker.HermesContainerName, bugReportDir+"/hermes.log"); err != nil { + zap.L().Warn(fmt.Sprintf("Couldn't dump Hermes logs: %v", err)) + } + } // system.json srcPath := filepath.Join(config.BasePath, "settings", "system.json") @@ -237,6 +234,16 @@ func dumpBugReport(bugReportDir, timestamp, contact, description string, piers [ zap.L().Warn(fmt.Sprintf("Couldn't copy service configs: %v", err)) } } + srcPath = filepath.Join(config.BasePath, "settings", "hermes.json") + destPath = filepath.Join(bugReportDir, "hermes.json") + if err := copyFile(srcPath, destPath); err != nil { + zap.L().Warn(fmt.Sprintf("Couldn't copy Hermes config: %v", err)) + } else if err := sanitizeJSON(destPath, "access_code", "provider_api_key", "web_api_key", "api_key"); err != nil { + zap.L().Error("Couldn't sanitize hermes.json! Removing from report") + if err := os.Remove(destPath); err != nil { + return fmt.Errorf("Error removing unsanitized Hermes config: %v", err) + } + } // current and previous syslogs sysLogs := lastTwoLogs() diff --git a/goseg/handler/system.go b/goseg/handler/system.go index ba0b4dea2..bebae4dc1 100644 --- a/goseg/handler/system.go +++ b/goseg/handler/system.go @@ -23,32 +23,6 @@ func SystemHandler(msg []byte) error { return fmt.Errorf("Couldn't unmarshal system payload: %v", err) } switch systemPayload.Payload.Action { - case "toggle-penpai-feature": - conf := config.Conf() - if conf.PenpaiAllow { - err := docker.StopContainerByName("llama-gpt-api") - if err != nil { - zap.L().Error(fmt.Sprintf("Failed to stop Llama API: %v", err)) - } - err = docker.StopContainerByName("llama-gpt-ui") - if err != nil { - zap.L().Error(fmt.Sprintf("Failed to stop Llama UI: %v", err)) - } - if err = config.UpdateConf(map[string]any{ - "penpaiAllow": false, - }); err != nil { - zap.L().Error(fmt.Sprintf("Couldn't toggle penpai feature: %v", err)) - } - } else { - if err = config.UpdateConf(map[string]any{ - "penpaiAllow": true, - }); err != nil { - zap.L().Error(fmt.Sprintf("Couldn't toggle penpai feature: %v", err)) - } - if err := docker.LoadLlama(); err != nil { - zap.L().Error(fmt.Sprintf("Failed to load llama docker: %v", err)) - } - } case "groundseg": zap.L().Info(fmt.Sprintf("Device shutdown requested")) switch systemPayload.Payload.Command { @@ -125,10 +99,18 @@ func SystemHandler(msg []byte) error { }() zap.L().Info(fmt.Sprintf("Swap successfully set to %v", systemPayload.Payload.Value)) case "update": - if systemPayload.Payload.Update == "linux" { + switch systemPayload.Payload.Update { + case "linux": if err := system.RunUpgrade(); err != nil { zap.L().Error(fmt.Sprintf("Error updating host system: %v", err)) } + case "check": + select { + case docker.UpdateCheckBus <- struct{}{}: + docker.SysTransBus <- structs.SystemTransition{Type: "checkUpdates", Event: "queued"} + default: + docker.SysTransBus <- structs.SystemTransition{Type: "checkUpdates", Event: "queued"} + } } case "wifi-toggle": if err := system.ToggleDevice(system.Device); err != nil { diff --git a/goseg/handler/urbit.go b/goseg/handler/urbit.go index 41add8c88..f63f9dbf6 100644 --- a/goseg/handler/urbit.go +++ b/goseg/handler/urbit.go @@ -14,6 +14,7 @@ import ( "net" "os" "path/filepath" + "slices" "strconv" "strings" "time" @@ -45,6 +46,8 @@ func UrbitHandler(msg []byte) error { return setUrbitDomain(patp, urbitPayload, shipConf) case "set-minio-domain": return setMinIODomain(patp, urbitPayload, shipConf) + case "remove-minio-domain": + return removeMinIODomain(patp, shipConf) // set whether or not ship wants startram reminders case "startram-reminder": return startramReminder(patp, urbitPayload.Payload.Remind, shipConf) @@ -52,10 +55,6 @@ func UrbitHandler(msg []byte) error { case "delete-service": return urbitDeleteStartramService(patp, urbitPayload.Payload.Service, shipConf) // urbit desks - case "install-penpai-companion": - return installPenpaiCompanion(patp, shipConf) - case "uninstall-penpai-companion": - return uninstallPenpaiCompanion(patp, shipConf) case "install-gallseg": // vere 3.0 return installGallseg(patp, shipConf) // vere 3.0 case "uninstall-gallseg": // vere 3.0 @@ -84,6 +83,8 @@ func UrbitHandler(msg []byte) error { return handleSnapTime(patp, urbitPayload, shipConf) case "extra-args": return handleExtraArgs(patp, urbitPayload, shipConf) + case "vere-tag": + return handleVereTag(patp, urbitPayload, shipConf) case "toggle-boot-status": return toggleBootStatus(patp, shipConf) case "toggle-auto-reboot": @@ -248,90 +249,6 @@ func urbitCleanDelete(patp string) error { return nil } -func installPenpaiCompanion(patp string, shipConf structs.UrbitDocker) error { - // run after complete - defer func(patp string) { - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: ""} - }(patp) - - // initial transition - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: "loading"} - - // error handling - handleError := func(patp, errMsg string, err error) error { - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: "error"} - time.Sleep(3 * time.Second) - return fmt.Errorf("%s: %s: %v", patp, errMsg, err) - } - - // if not-found, |install, if suspended, |revive - status, err := click.GetDesk(patp, "penpai", true) - if err != nil { - return handleError(patp, "Handler failed to get penpai desk info", err) - } - if status == "not-found" { - err := click.InstallDesk(patp, "~nattyv", "penpai") - if err != nil { - return handleError(patp, "Handler failed to get install penpai desk", err) - } - } else if status == "suspended" { - err := click.ReviveDesk(patp, "penpai") - if err != nil { - return handleError(patp, "Handler failed to revive penpai desk", err) - } - } - // wait for complete - for { - time.Sleep(5 * time.Second) - status, err := click.GetDesk(patp, "penpai", true) - if err != nil { - return handleError(patp, "Handler failed to get penpai desk info after installation succeeded", err) - } - if status == "running" { - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: "success"} - time.Sleep(3 * time.Second) - break - } - } - return nil -} - -func uninstallPenpaiCompanion(patp string, shipConf structs.UrbitDocker) error { - // run after complete - defer func(patp string) { - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: ""} - }(patp) - - // initial transition - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: "loading"} - - // error handling - handleError := func(patp, errMsg string, err error) error { - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: "error"} - time.Sleep(3 * time.Second) - return fmt.Errorf("%s: %s: %v", patp, errMsg, err) - } - - // uninstall - err := click.UninstallDesk(patp, "penpai") - if err != nil { - return handleError(patp, "Handler failed to install uninstall the penpai desk", err) - } - for { - time.Sleep(5 * time.Second) - status, err := click.GetDesk(patp, "penpai", true) - if err != nil { - return handleError(patp, "Handler failed to get penpai desk info after uninstallation succeeded", err) - } - if status != "running" { - docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "penpaiCompanion", Event: "success"} - time.Sleep(3 * time.Second) - break - } - } - return nil -} - func installGallseg(patp string, shipConf structs.UrbitDocker) error { // run after complete defer func(patp string) { @@ -730,6 +647,50 @@ func setMinIODomain(patp string, urbitPayload structs.WsUrbitPayload, shipConf s return nil } +func removeMinIODomain(patp string, shipConf structs.UrbitDocker) error { + defer func() { + time.Sleep(1 * time.Second) + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "minioDomain", Event: ""} + }() + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "minioDomain", Event: "loading"} + + conf := config.Conf() + oldShipConf := shipConf + alias := strings.TrimSpace(docker.ObjectStoreCustomDomain(conf, shipConf)) + if alias == "" { + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "minioDomain", Event: "error"} + return fmt.Errorf("RustFS custom domain is not configured for %s", patp) + } + + if docker.ObjectStoreUsesRemoteDomain(conf, shipConf) { + if err := startram.AliasDelete(fmt.Sprintf("s3.%s", patp), alias); err != nil { + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "minioDomain", Event: "error"} + return err + } + } + + docker.ClearObjectStoreCustomDomain(conf, &shipConf) + update := map[string]structs.UrbitDocker{patp: shipConf} + if err := config.UpdateUrbitConfig(update); err != nil { + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "minioDomain", Event: "error"} + return fmt.Errorf("Couldn't update urbit config: %v", err) + } + if err := recreateObjectStoreContainer(patp); err != nil { + rollback := map[string]structs.UrbitDocker{patp: oldShipConf} + if rollbackErr := config.UpdateUrbitConfig(rollback); rollbackErr != nil { + zap.L().Error(fmt.Sprintf("Couldn't roll back RustFS domain removal for %s after recreate failure: %v", patp, rollbackErr)) + } else if rollbackStartErr := recreateObjectStoreContainer(patp); rollbackStartErr != nil { + zap.L().Error(fmt.Sprintf("Couldn't restore RustFS container for %s after recreate failure: %v", patp, rollbackStartErr)) + } + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "minioDomain", Event: "error"} + return err + } + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "minioDomain", Event: "success"} + time.Sleep(3 * time.Second) + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "minioDomain", Event: "done"} + return nil +} + func ChopPier(patp string, shipConf structs.UrbitDocker) error { return performChop(patp, shipConf, "chop", false, false) } @@ -758,6 +719,7 @@ func toggleChopOnVereUpdate(patp string, shipConf structs.UrbitDocker) error { func deleteShip(patp string, shipConf structs.UrbitDocker) error { conf := config.Conf() + disableHermesIfAssignedTo(patp) // update DesiredStatus to 'stopped' contConf := config.GetContainerState() patpConf := contConf[patp] @@ -1003,6 +965,7 @@ func toggleNetwork(patp string, shipConf structs.UrbitDocker) error { if err := recreateObjectStoreContainer(patp); err != nil { return err } + restartHermesForShipIfEnabled(patp) return nil } @@ -1219,6 +1182,53 @@ func handleExtraArgs(patp string, urbitPayload structs.WsUrbitPayload, shipConf return nil } +func handleVereTag(patp string, urbitPayload structs.WsUrbitPayload, shipConf structs.UrbitDocker) error { + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "vereTag", Event: "loading"} + fail := func(message string, err error) error { + text := fmt.Sprintf("%s: %v", message, err) + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "vereTag", Event: text} + time.Sleep(3 * time.Second) + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "vereTag", Event: ""} + return err + } + + tag := strings.TrimSpace(urbitPayload.Payload.VereTag) + if tag != "" { + tags, err := docker.GetVereImageTags() + if err != nil { + return fail("Couldn't fetch Vere image tags", err) + } + if !slices.Contains(tags, tag) { + return fail("Invalid Vere image tag", fmt.Errorf("%q is not present on Docker Hub", tag)) + } + } + + shipConf.UrbitImageTagOverride = tag + update := make(map[string]structs.UrbitDocker) + update[patp] = shipConf + if err := config.UpdateUrbitConfig(update); err != nil { + return fail("Couldn't update urbit config", err) + } + if err := urbitCleanDelete(patp); err != nil { + zap.L().Error(fmt.Sprintf("Container deletion for Vere tag rebuild failed: %v", err)) + } + + if shipConf.BootStatus != "noboot" { + if _, err := docker.StartContainer(patp, "vere"); err != nil { + return fail(fmt.Sprintf("Couldn't start %s", patp), err) + } + } else { + if _, err := docker.CreateContainer(patp, "vere"); err != nil { + return fail(fmt.Sprintf("Couldn't create %s", patp), err) + } + } + restartHermesForShipIfEnabled(patp) + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "vereTag", Event: "success"} + time.Sleep(3 * time.Second) + docker.UTransBus <- structs.UrbitTransition{Patp: patp, Type: "vereTag", Event: ""} + return nil +} + func schedulePack(patp string, urbitPayload structs.WsUrbitPayload, shipConf structs.UrbitDocker) error { frequency := urbitPayload.Payload.Frequency // frequency not 0 diff --git a/goseg/main.go b/goseg/main.go index af7837120..741997646 100644 --- a/goseg/main.go +++ b/goseg/main.go @@ -178,14 +178,25 @@ func startServer() { // *http.Server { } func fallbackToIndex(fs http.FileSystem) http.HandlerFunc { + fileServer := http.FileServer(fs) return func(w http.ResponseWriter, r *http.Request) { file, err := fs.Open(r.URL.Path) - if err != nil { - r.URL.Path = "/index.html" - } else { + if err == nil { defer file.Close() + fileServer.ServeHTTP(w, r) + return } - http.FileServer(fs).ServeHTTP(w, r) + if filepath.Ext(r.URL.Path) != "" { + http.NotFound(w, r) + return + } + indexReq := new(http.Request) + *indexReq = *r + indexURL := *r.URL + indexURL.Path = "/" + indexURL.RawPath = "" + indexReq.URL = &indexURL + fileServer.ServeHTTP(w, indexReq) } } @@ -251,7 +262,10 @@ func main() { } else { versionStruct := config.LocalVersion() releaseChannel := conf.UpdateBranch - targetChan := versionStruct.Groundseg[releaseChannel] + targetChan, selectedChannel, exactChannel := config.SelectVersionChannel(versionStruct, releaseChannel) + if !exactChannel { + zap.L().Warn(fmt.Sprintf("Version channel %q not found locally; using %q", releaseChannel, selectedChannel)) + } config.VersionInfo = targetChan } // routines/version.go @@ -263,6 +277,8 @@ func main() { // digest urbit transition events go rectify.UrbitTransitionHandler() + // digest hermes profile transition events + go rectify.HermesTransitionHandler() // digest system transition events go rectify.SystemTransitionHandler() // digest new ship transition events @@ -314,7 +330,10 @@ func main() { zap.L().Warn("Could not retrieve version info after 10 seconds!") versionStruct := config.LocalVersion() releaseChannel := conf.UpdateBranch - targetChan := versionStruct.Groundseg[releaseChannel] + targetChan, selectedChannel, exactChannel := config.SelectVersionChannel(versionStruct, releaseChannel) + if !exactChannel { + zap.L().Warn(fmt.Sprintf("Version channel %q not found locally; using %q", releaseChannel, selectedChannel)) + } config.VersionInfo = targetChan } } @@ -342,9 +361,9 @@ func main() { loadService(docker.LoadNetdata, "Unable to load Netdata!") // Load Urbits loadService(docker.LoadUrbits, "Unable to load Urbit ships!") + // Load Hermes sidecars after ships so code-derived sidecars can connect. + loadService(docker.LoadHermes, "Unable to load Hermes containers!") // Auto-link S3 for ships that are still unlinked after RustFS provisioning. go routines.AutoConfigureObjectStoreLinks() - // Load Penpai - loadService(docker.LoadLlama, "Unable to load Llama GPT!") startServer() } diff --git a/goseg/main_test.go b/goseg/main_test.go new file mode 100644 index 000000000..536c1e87c --- /dev/null +++ b/goseg/main_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "io/fs" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" +) + +func TestFallbackToIndexStaticAssets(t *testing.T) { + webFS := http.FS(fstest.MapFS{ + "index.html": {Data: []byte("index")}, + "hermes.png": {Data: []byte("png")}, + }) + + tests := []struct { + name string + path string + wantCode int + wantBody string + }{ + {name: "static asset", path: "/hermes.png", wantCode: http.StatusOK, wantBody: "png"}, + {name: "missing static asset", path: "/missing.png", wantCode: http.StatusNotFound, wantBody: "404 page not found\n"}, + {name: "app route", path: "/profile", wantCode: http.StatusOK, wantBody: "index"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tt.path, nil) + rec := httptest.NewRecorder() + + fallbackToIndex(webFS)(rec, req) + + if rec.Code != tt.wantCode { + t.Fatalf("status = %d, want %d", rec.Code, tt.wantCode) + } + if got := rec.Body.String(); got != tt.wantBody { + t.Fatalf("body = %q, want %q", got, tt.wantBody) + } + if location := rec.Header().Get("Location"); location != "" { + t.Fatalf("unexpected redirect to %q", location) + } + }) + } +} + +var _ fs.FS = fstest.MapFS{} diff --git a/goseg/rectify/rectify.go b/goseg/rectify/rectify.go index b12d5c03d..4b9ced331 100644 --- a/goseg/rectify/rectify.go +++ b/goseg/rectify/rectify.go @@ -39,6 +39,8 @@ func UrbitTransitionHandler() { urbitStruct.Transition.SnapTime = event.Event case "extraArgs": urbitStruct.Transition.ExtraArgs = event.Event + case "vereTag": + urbitStruct.Transition.VereTag = event.Event case "urbitDomain": urbitStruct.Transition.UrbitDomain = event.Event case "minioDomain": @@ -63,8 +65,6 @@ func UrbitTransitionHandler() { urbitStruct.Transition.DeleteShip = event.Event case "toggleMinIOLink": urbitStruct.Transition.ToggleMinIOLink = event.Event - case "penpaiCompanion": - urbitStruct.Transition.PenpaiCompanion = event.Event case "gallseg": urbitStruct.Transition.Gallseg = event.Event case "deleteService": @@ -90,6 +90,44 @@ func UrbitTransitionHandler() { } } +func HermesTransitionHandler() { + for { + event := <-docker.HermesTransBus + current := broadcast.GetState() + switch event.Type { + case "toggle": + current.Profile.Hermes.Transition.Toggle = fmt.Sprintf("%v", event.Data) + case "save": + current.Profile.Hermes.Transition.Save = fmt.Sprintf("%v", event.Data) + case "restart": + current.Profile.Hermes.Transition.Restart = fmt.Sprintf("%v", event.Data) + case "install": + current.Profile.Hermes.Transition.Install = fmt.Sprintf("%v", event.Data) + case "error": + current.Profile.Hermes.Transition.Error = fmt.Sprintf("%v", event.Data) + default: + zap.L().Warn(fmt.Sprintf("Urecognized Hermes transition: %v", event.Type)) + continue + } + if event.Data == nil { + switch event.Type { + case "toggle": + current.Profile.Hermes.Transition.Toggle = "" + case "save": + current.Profile.Hermes.Transition.Save = "" + case "restart": + current.Profile.Hermes.Transition.Restart = "" + case "install": + current.Profile.Hermes.Transition.Install = "" + case "error": + current.Profile.Hermes.Transition.Error = "" + } + } + broadcast.UpdateBroadcast(current) + broadcast.BroadcastToClients() + } +} + func NewShipTransitionHandler() { for { event := <-docker.NewShipTransBus @@ -311,6 +349,8 @@ func SystemTransitionHandler() { switch event.Type { case "wifiConnect": current.System.Transition.WifiConnect = event.Event + case "checkUpdates": + current.System.Transition.CheckUpdates = event.Event case "swap": current.System.Transition.Swap = event.BoolEvent case "bugReport": diff --git a/goseg/routines/docker.go b/goseg/routines/docker.go index 1b8637c89..65ca8f72f 100644 --- a/goseg/routines/docker.go +++ b/goseg/routines/docker.go @@ -141,6 +141,16 @@ func DockerSubscriptionHandler() { } else { zap.L().Info(fmt.Sprintf("Ship desired status: %s", containerState.DesiredStatus)) } + } else if containerState.Type == "hermes" && containerState.DesiredStatus != "died" && containerState.DesiredStatus != "stopped" { + zap.L().Info("Attempting to restart Hermes after death") + go func(name, containerType string) { + time.Sleep(2 * time.Second) + if _, err := docker.StartContainer(name, containerType); err != nil { + zap.L().Error(fmt.Sprintf("Failed to restart %s after death: %v", name, err)) + } else { + zap.L().Info(fmt.Sprintf("Successfully restarted %s after death", name)) + } + }(contName, containerState.Type) } makeBroadcast(contName, string(dockerEvent.Action)) } else { @@ -176,6 +186,10 @@ func makeBroadcast(contName string, status string) { current := broadcast.GetState() current.Profile.Startram.Info.Running = wgOn broadcast.UpdateBroadcast(current) + case docker.HermesContainerName: + current := broadcast.GetState() + current.Profile.Hermes.Info.Running = status == "start" + broadcast.UpdateBroadcast(current) } broadcast.BroadcastToClients() } diff --git a/goseg/routines/version.go b/goseg/routines/version.go index 8ebc9a05e..46d4661d9 100644 --- a/goseg/routines/version.go +++ b/goseg/routines/version.go @@ -18,45 +18,79 @@ import ( "slices" "strconv" "strings" + "sync" "time" "github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier/verify" "go.uber.org/zap" ) +var versionUpdateMu sync.Mutex + func CheckVersionLoop() { conf := config.Conf() var updateInterval int updateInterval = max(conf.UpdateInterval, 60) checkInterval := time.Duration(updateInterval) * time.Second ticker := time.NewTicker(checkInterval) - releaseChannel := conf.UpdateBranch if conf.UpdateMode == "auto" { - callUpdater(releaseChannel) - for { - select { - case <-ticker.C: - callUpdater(releaseChannel) + callUpdater(conf.UpdateBranch) + } + for { + select { + case <-ticker.C: + if config.Conf().UpdateMode == "auto" { + callUpdater(config.Conf().UpdateBranch) } + case <-docker.UpdateCheckBus: + callUpdaterWithStatus(config.Conf().UpdateBranch) } } } func callUpdater(releaseChannel string) { - currentChannelVersion := config.LocalVersion().Groundseg[releaseChannel] + runUpdater(releaseChannel, false) +} + +func callUpdaterWithStatus(releaseChannel string) { + runUpdater(releaseChannel, true) +} + +func runUpdater(releaseChannel string, reportStatus bool) { + versionUpdateMu.Lock() + defer versionUpdateMu.Unlock() + reportUpdateStatus(reportStatus, "checking") + defer func() { + if reportStatus { + time.Sleep(3 * time.Second) + reportUpdateStatus(true, "") + } + }() + currentChannelVersion, selectedChannel, exactChannel := config.SelectVersionChannel(config.LocalVersion(), releaseChannel) + if !exactChannel { + zap.L().Warn(fmt.Sprintf("Version channel %q not found locally; using %q", releaseChannel, selectedChannel)) + } // Get latest information - latestVersion, _ := config.CheckVersion() + latestVersion, ok := config.CheckVersion() + if !ok { + reportUpdateStatus(reportStatus, "error") + return + } latestChannelVersion := latestVersion + dockerChanged := latestChannelVersion != currentChannelVersion // check docker updates - if latestChannelVersion != currentChannelVersion { - updateDocker(releaseChannel, currentChannelVersion, latestChannelVersion) + if dockerChanged { + updateDocker(releaseChannel, currentChannelVersion, latestChannelVersion, reportStatus) config.VersionInfo = latestVersion + } else { + reportUpdateStatus(reportStatus, "up-to-date") } // Check for gs binary updates based on hash binPath := filepath.Join(config.BasePath, "groundseg") currentHash, err := getSha256(binPath) if err != nil { zap.L().Error(fmt.Sprintf("Couldn't hash binary: %v", err)) + reportUpdateStatus(reportStatus, "error") return } latestHash := latestVersion.Groundseg.Amd64Sha256 @@ -65,9 +99,20 @@ func callUpdater(releaseChannel string) { } if currentHash != latestHash { zap.L().Info("GroundSeg Binary update!") + reportUpdateStatus(reportStatus, "updating groundseg") // updateBinary will likely restart the program, so // we don't have to care about the docker updates. updateBinary(releaseChannel, latestVersion) + return + } + if dockerChanged { + reportUpdateStatus(reportStatus, "success") + } +} + +func reportUpdateStatus(enabled bool, status string) { + if enabled { + docker.SysTransBus <- structs.SystemTransition{Type: "checkUpdates", Event: status} } } @@ -256,7 +301,7 @@ func contains(slice []string, item string) bool { return slices.Contains(slice, item) } -func updateDocker(release string, currentVersion structs.Channel, latestVersion structs.Channel) { +func updateDocker(release string, currentVersion structs.Channel, latestVersion structs.Channel, reportStatus bool) { zap.L().Info(fmt.Sprintf("update docker called: Current: %v , Latest %v", currentVersion, latestVersion)) zap.L().Info(fmt.Sprintf( "New version available in %s channel! Current: %+v, Latest: %+v\n", @@ -278,14 +323,16 @@ func updateDocker(release string, currentVersion structs.Channel, latestVersion for i := 0; i < valCurrent.NumField(); i++ { sw := strings.ToLower(typeOfVersion.Field(i).Name) - if sw != "groundseg" { + if sw != "groundseg" && sw != "hermes" { currentDetail := valCurrent.Field(i).Interface().(structs.VersionDetails) latestDetail := valLatest.Field(i).Interface().(structs.VersionDetails) if config.Architecture == "amd64" { if latestDetail.Amd64Sha256 != currentDetail.Amd64Sha256 { if contains([]string{"netdata", "wireguard"}, sw) { + reportUpdateStatus(reportStatus, "updating "+sw) docker.StartContainer(sw, sw) } else if sw == "vere" { + reportUpdateStatus(reportStatus, "updating vere") for pier, status := range statuses { isRunning := (status == "Up" || strings.HasPrefix(status, "Up ")) urbConf := config.UrbitConf(pier) @@ -353,6 +400,7 @@ func updateDocker(release string, currentVersion structs.Channel, latestVersion } } } else if sw == "minio" || sw == "rustfs" { + reportUpdateStatus(reportStatus, "updating "+sw) for pier, status := range statuses { isRunning := (status == "Up" || strings.HasPrefix(status, "Up ")) if isRunning { @@ -364,8 +412,10 @@ func updateDocker(release string, currentVersion structs.Channel, latestVersion } else { if latestDetail.Arm64Sha256 != currentDetail.Arm64Sha256 { if contains([]string{"netdata", "wireguard"}, sw) { + reportUpdateStatus(reportStatus, "updating "+sw) docker.StartContainer(sw, sw) } else if sw == "vere" { + reportUpdateStatus(reportStatus, "updating vere") for pier, status := range statuses { isRunning := (status == "Up" || strings.HasPrefix(status, "Up ")) urbConf := config.UrbitConf(pier) @@ -433,6 +483,7 @@ func updateDocker(release string, currentVersion structs.Channel, latestVersion } } } else if sw == "minio" || sw == "rustfs" { + reportUpdateStatus(reportStatus, "updating "+sw) for pier, status := range statuses { isRunning := (status == "Up" || strings.HasPrefix(status, "Up ")) if isRunning { diff --git a/goseg/structs/broadcast.go b/goseg/structs/broadcast.go index a13870c57..d7ac84e2d 100644 --- a/goseg/structs/broadcast.go +++ b/goseg/structs/broadcast.go @@ -17,18 +17,6 @@ type AuthBroadcast struct { // third party integrations type Apps struct { - Penpai PenpaiBroadcast `json:"penpai"` -} - -type PenpaiBroadcast struct { - Info struct { - Allowed bool `json:"allowed"` - Running bool `json:"running"` - ActiveCores int `json:"activeCores"` - MaxCores int `json:"maxCores"` - Models []string `json:"models"` - ActiveModel string `json:"activeModel"` - } `json:"info"` } // new ship @@ -60,6 +48,7 @@ type SystemTransitionBroadcast struct { BugReport string `json:"bugReport"` BugReportError string `json:"bugReportError"` WifiConnect string `json:"wifiConnect"` + CheckUpdates string `json:"checkUpdates"` } // broadcast payload subobject @@ -100,6 +89,7 @@ type SystemDrive struct { // broadcast payload subobject type Profile struct { Startram Startram `json:"startram"` + Hermes Hermes `json:"hermes"` } // broadcast payload subobject @@ -133,6 +123,43 @@ type StartramTransition struct { Restart string `json:"restart"` } +type Hermes struct { + Info struct { + Enabled bool `json:"enabled"` + Running bool `json:"running"` + URL string `json:"url"` + Ship string `json:"ship"` + Owner string `json:"owner"` + Port int `json:"port"` + Image string `json:"image"` + HermesVersion string `json:"hermesVersion"` + HermesAgentRef string `json:"hermesAgentRef"` + TlonAdapterVersion string `json:"tlonAdapterVersion"` + TlonAdapterRef string `json:"tlonAdapterRef"` + ModelProvider string `json:"modelProvider"` + Model string `json:"model"` + ProviderAPIKeySet bool `json:"providerApiKeySet"` + WebProvider string `json:"webProvider"` + WebAPIKeySet bool `json:"webApiKeySet"` + WebURL string `json:"webUrl"` + APIEnabled bool `json:"apiEnabled"` + APIKeySet bool `json:"apiKeySet"` + ImageInstalled bool `json:"imageInstalled"` + VersionServerImage string `json:"versionServerImage"` + UpdateAvailable bool `json:"updateAvailable"` + Ships []string `json:"ships"` + } `json:"info"` + Transition HermesTransition `json:"transition"` +} + +type HermesTransition struct { + Toggle string `json:"toggle"` + Save string `json:"save"` + Restart string `json:"restart"` + Install string `json:"install"` + Error string `json:"error"` +} + // broadcast payload subobject type Urbit struct { Info struct { @@ -150,6 +177,11 @@ type Urbit struct { SnapTime int `json:"snapTime"` ExtraArgs string `json:"extraArgs"` BootCommandBase string `json:"bootCommandBase"` + UrbitVersion string `json:"urbitVersion"` + UrbitRepo string `json:"urbitRepo"` + UrbitImageTagOverride string `json:"urbitImageTagOverride"` + VereTags []string `json:"vereTags"` + VersionServerVereTag string `json:"versionServerVereTag"` DevMode bool `json:"devMode"` DetectBootStatus bool `json:"detectBootStatus"` Remote bool `json:"remote"` @@ -165,7 +197,6 @@ type Urbit struct { PackTime string `json:"packTime"` PackDay string `json:"packDay"` PackDate int `json:"packDate"` - PenpaiCompanion bool `json:"penpaiCompanion"` Gallseg bool `json:"gallseg"` MinIOLinked bool `json:"minioLinked"` StartramReminder bool `json:"startramReminder"` @@ -202,7 +233,6 @@ type UrbitTransitionBroadcast struct { Loom string `json:"loom"` UrbitDomain string `json:"urbitDomain"` MinIODomain string `json:"minioDomain"` - PenpaiCompanion string `json:"penpaiCompanion"` Gallseg string `json:"gallseg"` ChopOnUpgrade string `json:"chopOnUpgrade"` RollChop string `json:"rollChop"` @@ -214,6 +244,7 @@ type UrbitTransitionBroadcast struct { HandleRestoreTlonBackup string `json:"handleRestoreTlonBackup"` SnapTime string `json:"snapTime"` ExtraArgs string `json:"extraArgs"` + VereTag string `json:"vereTag"` } // used to construct broadcast pier info subobject diff --git a/goseg/structs/configs.go b/goseg/structs/configs.go index afc993336..09431f493 100644 --- a/goseg/structs/configs.go +++ b/goseg/structs/configs.go @@ -49,11 +49,6 @@ type SysConfig struct { Pubkey string `json:"pubkey"` Privkey string `json:"privkey"` Salt string `json:"salt"` - PenpaiAllow bool `json:"penpaiAllow"` - PenpaiRunning bool `json:"penpaiRunning"` - PenpaiCores int `json:"penpaiCores"` - PenpaiModels []Penpai `json:"penpaiModels"` - PenpaiActive string `json:"penpaiActive"` DisableSlsa bool `json:"disableSlsa"` Disable502 bool `json:"disable502"` SnapTime int `json:"snapTime"` @@ -68,12 +63,6 @@ type DiskWarning struct { NinetyFive time.Time `json:"ninetyFive"` } -type Penpai struct { - ModelTitle string `json:"modelTitle"` - ModelName string `json:"modelName"` - ModelUrl string `json:"modelUrl"` -} - // authenticated browser sessions type SessionInfo struct { Hash string `json:"hash"` @@ -82,52 +71,53 @@ type SessionInfo struct { // pier json struct type UrbitDocker struct { - PierName string `json:"pier_name"` - HTTPPort int `json:"http_port"` - AmesPort int `json:"ames_port"` - LoomSize int `json:"loom_size"` - ExtraArgs string `json:"extra_args"` - UrbitVersion string `json:"urbit_version"` - MinioVersion string `json:"minio_version"` - UrbitRepo string `json:"urbit_repo"` - MinioRepo string `json:"minio_repo"` - UrbitAmd64Sha256 string `json:"urbit_amd64_sha256"` - UrbitArm64Sha256 string `json:"urbit_arm64_sha256"` - MinioAmd64Sha256 string `json:"minio_amd64_sha256"` - MinioArm64Sha256 string `json:"minio_arm64_sha256"` - MinioPassword string `json:"minio_password"` - Network string `json:"network"` - WgURL string `json:"wg_url"` - WgHTTPPort int `json:"wg_http_port"` - WgAmesPort int `json:"wg_ames_port"` - WgS3Port int `json:"wg_s3_port"` - WgConsolePort int `json:"wg_console_port"` - MeldSchedule bool `json:"meld_schedule"` - MeldScheduleType string `json:"meld_schedule_type"` - MeldDay string `json:"meld_day"` - MeldDate int `json:"meld_date"` - MeldFrequency int `json:"meld_frequency"` - MeldTime string `json:"meld_time"` - MeldLast string `json:"meld_last"` - MeldNext string `json:"meld_next"` - BootStatus string `json:"boot_status"` - CustomPierLocation any `json:"custom_pier_location"` - CustomUrbitWeb string `json:"custom_urbit_web"` - CustomS3Web string `json:"custom_s3_web"` - CustomS3WebLocal string `json:"custom_s3_web_local"` - CustomS3WebRemote string `json:"custom_s3_web_remote"` - ShowUrbitWeb string `json:"show_urbit_web"` - DevMode bool `json:"dev_mode"` - Click bool `json:"click"` - MinIOLinked bool `json:"minio_linked"` - StartramReminder any `json:"startram_reminder"` - ChopOnUpgrade any `json:"chop_on_upgrade"` - SizeLimit int `json:"size_limit"` - RemoteTlonBackup bool `json:"remote_tlon_backup"` - LocalTlonBackup bool `json:"local_tlon_backup"` - BackupTime string `json:"backup_time"` - DisableShipRestarts any `json:"disable_ship_restarts"` - SnapTime int `json:"snap_time"` + PierName string `json:"pier_name"` + HTTPPort int `json:"http_port"` + AmesPort int `json:"ames_port"` + LoomSize int `json:"loom_size"` + ExtraArgs string `json:"extra_args"` + UrbitVersion string `json:"urbit_version"` + UrbitImageTagOverride string `json:"urbit_image_tag_override"` + MinioVersion string `json:"minio_version"` + UrbitRepo string `json:"urbit_repo"` + MinioRepo string `json:"minio_repo"` + UrbitAmd64Sha256 string `json:"urbit_amd64_sha256"` + UrbitArm64Sha256 string `json:"urbit_arm64_sha256"` + MinioAmd64Sha256 string `json:"minio_amd64_sha256"` + MinioArm64Sha256 string `json:"minio_arm64_sha256"` + MinioPassword string `json:"minio_password"` + Network string `json:"network"` + WgURL string `json:"wg_url"` + WgHTTPPort int `json:"wg_http_port"` + WgAmesPort int `json:"wg_ames_port"` + WgS3Port int `json:"wg_s3_port"` + WgConsolePort int `json:"wg_console_port"` + MeldSchedule bool `json:"meld_schedule"` + MeldScheduleType string `json:"meld_schedule_type"` + MeldDay string `json:"meld_day"` + MeldDate int `json:"meld_date"` + MeldFrequency int `json:"meld_frequency"` + MeldTime string `json:"meld_time"` + MeldLast string `json:"meld_last"` + MeldNext string `json:"meld_next"` + BootStatus string `json:"boot_status"` + CustomPierLocation any `json:"custom_pier_location"` + CustomUrbitWeb string `json:"custom_urbit_web"` + CustomS3Web string `json:"custom_s3_web"` + CustomS3WebLocal string `json:"custom_s3_web_local"` + CustomS3WebRemote string `json:"custom_s3_web_remote"` + ShowUrbitWeb string `json:"show_urbit_web"` + DevMode bool `json:"dev_mode"` + Click bool `json:"click"` + MinIOLinked bool `json:"minio_linked"` + StartramReminder any `json:"startram_reminder"` + ChopOnUpgrade any `json:"chop_on_upgrade"` + SizeLimit int `json:"size_limit"` + RemoteTlonBackup bool `json:"remote_tlon_backup"` + LocalTlonBackup bool `json:"local_tlon_backup"` + BackupTime string `json:"backup_time"` + DisableShipRestarts any `json:"disable_ship_restarts"` + SnapTime int `json:"snap_time"` } // Define the interface @@ -186,6 +176,8 @@ func (u *UrbitDocker) UnmarshalJSON(data []byte) error { u.ExtraArgs, _ = v.(string) case "urbit_version": u.UrbitVersion, _ = v.(string) + case "urbit_image_tag_override": + u.UrbitImageTagOverride, _ = v.(string) case "minio_version": u.MinioVersion, _ = v.(string) case "urbit_repo": @@ -316,6 +308,27 @@ type McConfig struct { Arm64Sha256 string `json:"arm64_sha256"` } +type HermesConfig struct { + Enabled bool `json:"enabled"` + Ship string `json:"ship"` + Owner string `json:"owner"` + Port int `json:"port"` + Image string `json:"image"` + HermesVersion string `json:"hermes_version"` + HermesAgentRef string `json:"hermes_agent_ref"` + TlonAdapterVersion string `json:"tlon_adapter_version"` + TlonAdapterRef string `json:"tlon_adapter_ref"` + ModelProvider string `json:"model_provider"` + Model string `json:"model"` + ProviderAPIKey string `json:"provider_api_key"` + WebProvider string `json:"web_provider"` + WebAPIKey string `json:"web_api_key"` + WebURL string `json:"web_url"` + APIEnabled bool `json:"api_enabled"` + APIKey string `json:"api_key"` + AccessCode string `json:"access_code"` +} + // nedata config json type NetdataConfig struct { NetdataName string `json:"netdata_name"` diff --git a/goseg/structs/custom_s3.go b/goseg/structs/custom_s3.go index 8b8b93b58..06c97f73b 100644 --- a/goseg/structs/custom_s3.go +++ b/goseg/structs/custom_s3.go @@ -14,7 +14,7 @@ func LegacyCustomS3Domain(conf UrbitDocker) string { func SyncCustomS3Domains(conf *UrbitDocker) { legacyDomain := strings.TrimSpace(conf.CustomS3Web) - if legacyDomain != "" { + if legacyDomain != "" && strings.TrimSpace(conf.CustomS3WebLocal) == "" && strings.TrimSpace(conf.CustomS3WebRemote) == "" { if strings.TrimSpace(conf.CustomS3WebLocal) == "" { conf.CustomS3WebLocal = legacyDomain } diff --git a/goseg/structs/version.go b/goseg/structs/version.go index 77655a79c..d5d65da44 100644 --- a/goseg/structs/version.go +++ b/goseg/structs/version.go @@ -14,6 +14,7 @@ type Channel struct { Miniomc VersionDetails `json:"miniomc"` Netdata VersionDetails `json:"netdata"` Vere VersionDetails `json:"vere"` + Hermes VersionDetails `json:"hermes"` Webui VersionDetails `json:"webui"` Wireguard VersionDetails `json:"wireguard"` } diff --git a/goseg/structs/ws.go b/goseg/structs/ws.go index 5d01ff813..10e5f44c7 100644 --- a/goseg/structs/ws.go +++ b/goseg/structs/ws.go @@ -291,26 +291,13 @@ type WsDevPayload struct { Token WsTokenStruct `json:"token"` } -type WsPenpaiPayload struct { - ID string `json:"id"` - Type string `json:"type"` - Payload WsPenpaiAction `json:"payload"` - Token WsTokenStruct `json:"token"` -} - -type WsPenpaiAction struct { - Type string `json:"type"` - Action string `json:"action"` - Model string `json:"model"` - Cores int `json:"cores"` -} - type WsUrbitAction struct { Type string `json:"type"` Action string `json:"action"` Patp string `json:"patp"` Value int `json:value"` ExtraArgs string `json:"extraArgs"` + VereTag string `json:"vereTag"` Domain string `json:"domain"` Frequency int `json:"frequency"` IntervalType string `json:"intervalType"` @@ -326,6 +313,30 @@ type WsUrbitAction struct { BakType string `json:"bakType"` } +type WsHermesPayload struct { + ID string `json:"id"` + Type string `json:"type"` + Payload WsHermesAction `json:"payload"` + Token WsTokenStruct `json:"token"` +} + +type WsHermesAction struct { + Type string `json:"type"` + Action string `json:"action"` + Ship string `json:"ship"` + Owner string `json:"owner"` + Port int `json:"port"` + Image string `json:"image"` + Model string `json:"model"` + ModelProvider string `json:"modelProvider"` + ProviderAPIKey string `json:"providerApiKey"` + WebProvider string `json:"webProvider"` + WebAPIKey string `json:"webApiKey"` + WebURL string `json:"webUrl"` + APIEnabled bool `json:"apiEnabled"` + APIKey string `json:"apiKey"` +} + type WsDevAction struct { Type string `json:"type"` Action string `json:"action"` @@ -510,7 +521,6 @@ type WsSupportAction struct { Description string `json:"description"` Ships []string `json:"ships"` CPUProfile bool `json:"cpu_profile"` - Penpai bool `json:"penpai"` } type WsC2cPayload struct { diff --git a/goseg/ws/shell.go b/goseg/ws/shell.go index af26cdbf6..87208720f 100644 --- a/goseg/ws/shell.go +++ b/goseg/ws/shell.go @@ -20,10 +20,11 @@ import ( ) type ShellInitPayload struct { - Patp string `json:"patp"` - Cols uint `json:"cols"` - Rows uint `json:"rows"` - Token structs.WsTokenStruct `json:"token"` + Patp string `json:"patp"` + Target string `json:"target"` + Cols uint `json:"cols"` + Rows uint `json:"rows"` + Token structs.WsTokenStruct `json:"token"` } type ShellClientMessage struct { @@ -83,23 +84,13 @@ func ShellHandler(w http.ResponseWriter, r *http.Request) { return } - patp := strings.TrimSpace(init.Patp) - if patp == "" { - _ = writer.writeJSON(ShellServerMessage{Type: "error", Message: "Missing ship name"}) - return - } - - shipConf := config.UrbitConf(patp) - if shipConf.PierName == "" { - _ = writer.writeJSON(ShellServerMessage{Type: "error", Message: "Ship not found"}) - return + target := strings.TrimSpace(init.Target) + if target == "" { + target = "ship" } - if !shipConf.DevMode { - _ = writer.writeJSON(ShellServerMessage{Type: "error", Message: "Developer mode must be enabled"}) - return - } - if _, err := docker.GetContainerRunningStatus(patp); err != nil { - _ = writer.writeJSON(ShellServerMessage{Type: "error", Message: "Ship container is not running"}) + containerName, execCommand, errMessage := resolveShellTarget(target, strings.TrimSpace(init.Patp)) + if errMessage != "" { + _ = writer.writeJSON(ShellServerMessage{Type: "error", Message: errMessage}) return } @@ -113,9 +104,9 @@ func ShellHandler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - containerID, err := docker.GetContainerIDByName(ctx, cli, patp) + containerID, err := docker.GetContainerIDByName(ctx, cli, containerName) if err != nil { - _ = writer.writeJSON(ShellServerMessage{Type: "error", Message: "Could not find running ship container"}) + _ = writer.writeJSON(ShellServerMessage{Type: "error", Message: "Could not find running container"}) return } @@ -124,7 +115,7 @@ func ShellHandler(w http.ResponseWriter, r *http.Request) { AttachStdout: true, AttachStderr: true, Tty: true, - Cmd: []string{"tmux", "a"}, + Cmd: execCommand, }) if err != nil { _ = writer.writeJSON(ShellServerMessage{Type: "error", Message: "Failed to start shell session"}) @@ -143,7 +134,7 @@ func ShellHandler(w http.ResponseWriter, r *http.Request) { Width: init.Cols, Height: init.Rows, }); err != nil { - zap.L().Warn(fmt.Sprintf("initial shell resize failed for %s: %v", patp, err)) + zap.L().Warn(fmt.Sprintf("initial shell resize failed for %s: %v", containerName, err)) } } @@ -207,7 +198,7 @@ func ShellHandler(w http.ResponseWriter, r *http.Request) { Width: message.Cols, Height: message.Rows, }); err != nil { - zap.L().Warn(fmt.Sprintf("shell resize failed for %s: %v", patp, err)) + zap.L().Warn(fmt.Sprintf("shell resize failed for %s: %v", containerName, err)) } case "close": inputDone <- nil @@ -221,12 +212,12 @@ func ShellHandler(w http.ResponseWriter, r *http.Request) { cancel() hijackedResp.Close() if err != nil && !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { - zap.L().Debug(fmt.Sprintf("shell input closed for %s: %v", patp, err)) + zap.L().Debug(fmt.Sprintf("shell input closed for %s: %v", containerName, err)) } case err := <-outputDone: cancel() if err != nil { - zap.L().Debug(fmt.Sprintf("shell output closed for %s: %v", patp, err)) + zap.L().Debug(fmt.Sprintf("shell output closed for %s: %v", containerName, err)) } inspect, inspectErr := cli.ContainerExecInspect(context.Background(), execResp.ID) if inspectErr != nil { @@ -236,3 +227,30 @@ func ShellHandler(w http.ResponseWriter, r *http.Request) { _ = writer.writeJSON(ShellServerMessage{Type: "exit", Code: inspect.ExitCode}) } } + +func resolveShellTarget(target string, patp string) (string, []string, string) { + switch target { + case "ship": + if patp == "" { + return "", nil, "Missing ship name" + } + shipConf := config.UrbitConf(patp) + if shipConf.PierName == "" { + return "", nil, "Ship not found" + } + if !shipConf.DevMode { + return "", nil, "Developer mode must be enabled" + } + if _, err := docker.GetContainerRunningStatus(patp); err != nil { + return "", nil, "Ship container is not running" + } + return patp, []string{"tmux", "a"}, "" + case "hermes": + if _, err := docker.GetContainerRunningStatus(docker.HermesContainerName); err != nil { + return "", nil, "Hermes container is not running" + } + return docker.HermesContainerName, []string{"bash", "-lc", "if command -v tmux >/dev/null 2>&1 && tmux has-session -t hermes 2>/dev/null; then exec tmux attach -t hermes:shell; fi; if command -v tmux >/dev/null 2>&1; then exec tmux new -A -s hermes-shell; fi; exec bash -l"}, "" + default: + return "", nil, "Unsupported shell target" + } +} diff --git a/goseg/ws/ws.go b/goseg/ws/ws.go index 999821b8f..8d3a1c593 100644 --- a/goseg/ws/ws.go +++ b/goseg/ws/ws.go @@ -181,11 +181,6 @@ func WsHandler(w http.ResponseWriter, r *http.Request) { zap.L().Error(fmt.Sprintf("%v", err)) ack = "nack" } - case "penpai": - if err = handler.PenpaiHandler(msg); err != nil { - zap.L().Error(fmt.Sprintf("%v", err)) - ack = "nack" - } case "new_ship": if err = handler.NewShipHandler(msg); err != nil { zap.L().Error(fmt.Sprintf("%v", err)) @@ -217,6 +212,11 @@ func WsHandler(w http.ResponseWriter, r *http.Request) { zap.L().Error(fmt.Sprintf("%v", err)) ack = "nack" } + case "hermes": + if err = handler.HermesHandler(msg); err != nil { + zap.L().Error(fmt.Sprintf("%v", err)) + ack = "nack" + } case "urbit": if err = handler.UrbitHandler(msg); err != nil { zap.L().Error(fmt.Sprintf("%v", err)) diff --git a/ui/src/lib/ToggleButton.svelte b/ui/src/lib/ToggleButton.svelte index f2d8899f8..bc02349ab 100644 --- a/ui/src/lib/ToggleButton.svelte +++ b/ui/src/lib/ToggleButton.svelte @@ -3,6 +3,7 @@ const dispatch = createEventDispatcher() export let on = false export let loading = false + export let disabled = false let lastUserAction = null; let lastActionTime = 0; $: effectiveState = shouldIgnoreBackendState() ? lastUserAction : on; @@ -12,7 +13,7 @@ } function handleClick() { - if (!loading) { + if (!loading && !disabled) { lastUserAction = !on; lastActionTime = Date.now(); dispatch('click'); @@ -26,6 +27,7 @@
On
Off
@@ -96,7 +98,7 @@ font-style: normal; font-weight: 300; line-height: 32px; /* 133.333% */ - letter-spacing: -1.44px; + letter-spacing: 0; width: 47px; height: 47px; } @@ -105,4 +107,9 @@ pointer-events: none; transition: opacity 0.3s ease; } - \ No newline at end of file + .disabled { + opacity: .45; + pointer-events: none; + transition: opacity 0.3s ease; + } + diff --git a/ui/src/lib/stores/config-files.js b/ui/src/lib/stores/config-files.js new file mode 100644 index 000000000..6304b0963 --- /dev/null +++ b/ui/src/lib/stores/config-files.js @@ -0,0 +1,44 @@ +import { get } from 'svelte/store' +import { URBIT_MODE } from './data' +import { loadSession } from './gs-crypto' +import { wsPort } from './websocket' + +const apiBase = () => { + const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:' + return `${protocol}//${window.location.hostname}:${get(wsPort)}` +} + +const withToken = async payload => { + const token = await loadSession() + if (!token?.id || !token?.token) { + throw new Error('GroundSeg login required') + } + return { ...payload, token } +} + +export const configFileRequest = async payload => { + if (get(URBIT_MODE)) { + throw new Error('Config editor is only available from GroundSeg') + } + const response = await fetch(`${apiBase()}/config/files`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(await withToken(payload)) + }) + let body = {} + try { + body = await response.json() + } catch (error) { + throw new Error(`Config request failed: ${response.status}`) + } + if (!response.ok || body.ok === false) { + throw new Error(body.error || `Config request failed: ${response.status}`) + } + return body +} + +export const listConfigFiles = () => configFileRequest({ action: 'list' }) + +export const readConfigFile = file => configFileRequest({ action: 'read', file }) + +export const saveConfigFile = (file, content) => configFileRequest({ action: 'save', file, content }) diff --git a/ui/src/lib/stores/websocket.js b/ui/src/lib/stores/websocket.js index 2e156a78f..619eeb237 100644 --- a/ui/src/lib/stores/websocket.js +++ b/ui/src/lib/stores/websocket.js @@ -421,6 +421,15 @@ export const updateLinux = () => { send(payload) } +export const checkUpdates = () => { + let payload = { + "type":"system", + "action":"update", + "update":"check" + } + send(payload) +} + export const setSwap = val => { let payload = { "type":"system", @@ -522,6 +531,49 @@ export const setAllStartramReminder = remind => { send(payload) } +// +// Hermes +// + +export const hermesInstall = config => { + send({ + "type":"hermes", + "action":"install", + ...config + }) +} + +export const hermesUpdate = config => { + send({ + "type":"hermes", + "action":"update", + ...config + }) +} + +export const hermesToggle = config => { + send({ + "type":"hermes", + "action":"toggle", + ...config + }) +} + +export const hermesSave = config => { + send({ + "type":"hermes", + "action":"save", + ...config + }) +} + +export const hermesRestart = () => { + send({ + "type":"hermes", + "action":"restart" + }) +} + // // Upload Pier // @@ -729,6 +781,15 @@ export const setRustFSDomain = (patp, domain) => { setMinIODomain(patp, domain) } +export const removeRustFSDomain = patp => { + let payload = { + "type":"urbit", + "action":"remove-minio-domain", + "patp":patp, + } + send(payload) +} + export const setUrbitDomain = (patp, domain) => { let payload = { "type":"urbit", @@ -856,6 +917,16 @@ export const setUrbitExtraArgs = (patp, extraArgs) => { send(payload) } +export const setVereTag = (patp, vereTag) => { + let payload = { + "type":"urbit", + "action":"vere-tag", + "patp":patp, + "vereTag": vereTag + } + send(payload) +} + export const setPackSchedule = (patp, frequency, intervalType, time, day, date) => { let payload = { "type":"urbit", @@ -947,15 +1018,14 @@ export const setStartramReminder = (patp, remind) => { // Support // -export const submitReport = (contact,description,ships,cpuProfile,penpai) => { +export const submitReport = (contact,description,ships,cpuProfile) => { let payload = { "type":"support", "action":"bug-report", "contact":contact, "description":description, "ships":ships, - "cpu_profile":cpuProfile, - "penpai":penpai + "cpu_profile":cpuProfile } send(payload) } @@ -986,70 +1056,6 @@ export const submitNetwork = (ssid,password) => { send(payload) } -// -// Penpai -// - -export const toggleExperimentalPenpai = () => { - let payload = { - "type":"system", - "action": "toggle-penpai-feature", - } - send(payload) -} - -export const togglePenpai = () => { - let payload = { - "type":"penpai", - "action": "toggle", - } - send(payload) -} - -export const setPenpaiModel = model => { - let payload = { - "type":"penpai", - "action": "set-model", - "model": model - } - send(payload) -} - -export const setPenpaiCores = cores => { - let payload = { - "type":"penpai", - "action": "set-cores", - "cores": cores - } - send(payload) -} - -export const removePenpai = () => { - let payload = { - "type":"penpai", - "action": "remove" - } - send(payload) -} - -export const installPenpaiCompanion = patp => { - let payload = { - "type":"urbit", - "action":"install-penpai-companion", - "patp":patp, - } - send(payload) -} - -export const uninstallPenpaiCompanion = patp => { - let payload = { - "type":"urbit", - "action":"uninstall-penpai-companion", - "patp":patp, - } - send(payload) -} - export const installGallseg = patp => { let payload = { "type":"urbit", diff --git a/ui/src/routes/[patp]/Header.svelte b/ui/src/routes/[patp]/Header.svelte index e0297c321..0a0a947ab 100644 --- a/ui/src/routes/[patp]/Header.svelte +++ b/ui/src/routes/[patp]/Header.svelte @@ -1,7 +1,7 @@ @@ -33,7 +78,42 @@
{shipClass} - {vere.toUpperCase()} + 0} + class:error={vereError.length > 0} + bind:this={versionMenu}> + + {#if versionMenuOpen} +
+ {#each versionOptions as option} + + {/each} +
+ {/if} +
+ {#if isSavingVere} + SAVING + {:else if tVereTag == "success"} + SAVED + {:else if vereError.length > 0} + ERROR + {/if}
{#if copied} @@ -87,6 +167,109 @@ margin: 28px 0 12px 20px; font-size: 18px; } + .version-control { + display: inline-flex; + position: relative; + vertical-align: super; + margin-left: 5px; + z-index: 10; + } + .version-control::after { + content: ""; + position: absolute; + right: 7px; + top: 12px; + width: 0; + height: 0; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + border-top: 4px solid var(--text-card-color); + pointer-events: none; + } + .version-control.open::after { + transform: rotate(180deg); + } + .version-control > button { + background: transparent; + border: 1px solid var(--Gray-400, #5C7060); + border-radius: 4px; + color: var(--text-card-color); + cursor: pointer; + font-family: var(--title-font); + font-size: 18px; + font-weight: 700; + height: 30px; + letter-spacing: 0; + line-height: 28px; + max-width: 150px; + min-width: 74px; + overflow: hidden; + padding: 0 20px 0 7px; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + .version-control.override > button { + background: var(--Gray-400, #5C7060); + } + .version-control.error > button { + border-color: #d45151; + color: #ffd4d4; + } + .version-control > button:disabled { + cursor: default; + opacity: .6; + } + .version-menu { + position: absolute; + top: 34px; + left: 0; + width: 162px; + max-height: 300px; + overflow-y: auto; + border: 1px solid var(--Gray-400, #5C7060); + border-radius: 6px; + background: var(--bg-modal, #F5F1E8); + box-shadow: 0 10px 24px rgba(0, 0, 0, .22); + padding: 4px; + } + .version-menu button { + display: block; + width: 100%; + height: 29px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--NP_Black, #313933); + cursor: pointer; + font-family: var(--title-font); + font-size: 18px; + font-weight: 700; + letter-spacing: 0; + overflow: hidden; + padding: 0 8px; + text-align: left; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + .version-menu button:hover, + .version-menu button.active { + background: var(--Gray-400, #5C7060); + color: #fff; + } + .version-menu button.default { + border-bottom: 1px solid rgba(49, 57, 51, .18); + } + .version-status { + margin-left: 5px; + color: var(--Gray-300, #8FA393); + font-size: 10px; + letter-spacing: 0; + } + .version-status.error { + color: #d45151; + } .patp { cursor: pointer; font-family: var(--title-font); @@ -112,21 +295,4 @@ .settings-text { flex: 1; } - .btn { - width: 30%; - font-family: var(--regular-font); - font-size: 12px; - line-height: 32px; - background-color: var(--btn-secondary); - color: var(--text-card-color); - border-radius: 8px; - cursor: pointer; - } - .btn:hover { - background: var(--bg-card); - } - .btn:disabled { - pointer-events: none; - opacity: .6; - } diff --git a/ui/src/routes/[patp]/Section/CustomMinIODomain.svelte b/ui/src/routes/[patp]/Section/CustomMinIODomain.svelte index 87f0d086c..cf1752e61 100644 --- a/ui/src/routes/[patp]/Section/CustomMinIODomain.svelte +++ b/ui/src/routes/[patp]/Section/CustomMinIODomain.svelte @@ -1,7 +1,7 @@ - +
-
Web Shell
+
{title}
{status}
-
+
@@ -237,8 +246,8 @@ text-align: right; } .terminal { - height: 60vh; - min-height: 420px; + height: var(--terminal-height, 60vh); + min-height: var(--terminal-min-height, 420px); border-radius: 16px; overflow: hidden; border: 1px solid #3E5142; diff --git a/ui/src/routes/apps/+page.svelte b/ui/src/routes/apps/+page.svelte index c901dfb47..6e88676f7 100644 --- a/ui/src/routes/apps/+page.svelte +++ b/ui/src/routes/apps/+page.svelte @@ -1,8 +1,9 @@ - -
-
-
PENPAI {!penpaiAllowed ? "(DISABLED)" : ""}
- {#if penpaiAllowed} - - {/if} -
- - {#if penpaiAllowed} - - -
-
-
Model
-
showModels = !showModels}> -
{selectedModel.length < 1 ? "Select a model" : selectedModel}
-
- {#if showModels} - - {:else} - - {/if} -
-
-
- - {#if showModels} -
- {#each models as n} -
{selectModel(n)}}>{n}
- {/each} -
- {/if} -
- - {#if selectedModel != activeModel} -
- -
- {/if} - - {#if urbitKeys.length > 0} -
Install Companion App
-
- {#each urbitKeys as p} -
handlePenpaiCompanion(p)}> - {#if urbits?.[p]?.transition?.penpaiCompanion == "loading"} -
- {:else} -
- {#if urbits?.[p]?.info?.penpaiCompanion} - checkmark - {/if} -
- {/if} -
{p}
-
- {/each} -
- {/if} - - {/if} -
- - diff --git a/ui/src/routes/apps/apps.css b/ui/src/routes/apps/apps.css index b35441286..d3a6db02f 100644 --- a/ui/src/routes/apps/apps.css +++ b/ui/src/routes/apps/apps.css @@ -1,7 +1,7 @@ .keys-shell { width: 1104px; max-width: 98vw; - margin: 0 auto 48px auto; + margin: 0 auto 64px auto; color: var(--text-color); font-family: var(--regular-font); } @@ -12,14 +12,14 @@ box-sizing: border-box; display: flex; flex-direction: column; - min-height: 420px; - padding: 32px 40px; + min-height: 560px; + padding: 44px 48px; } .keys-row { border-top: 1px solid rgba(22, 29, 23, 0.16); box-sizing: border-box; - padding: 18px 0; + padding: 26px 0; } .keys-row:first-child { @@ -32,7 +32,7 @@ grid-template-columns: minmax(160px, 1fr) minmax(280px, 420px); gap: 24px; align-items: end; - padding-bottom: 22px; + padding-bottom: 30px; } h1, @@ -52,11 +52,12 @@ h2 { } h1 { - font-size: 48px; + font-size: 36px; + letter-spacing: 0; } h2 { - font-size: 26px; + font-size: 22px; } .roller-control { @@ -75,7 +76,7 @@ h2 { .state-row span, .boot-row span, .advanced summary { - font-family: var(--log-font); + font-family: var(--regular-font); } code, @@ -90,14 +91,14 @@ pre { .state-row span, .boot-row span { color: rgba(49, 57, 51, 0.72); - font-size: 12px; - font-weight: 700; + font-size: 13px; + font-weight: 600; } .identity-row { display: grid; - grid-template-columns: minmax(260px, 1fr) auto minmax(360px, 440px) 180px; - gap: 10px; + grid-template-columns: 76px minmax(260px, 1fr) auto minmax(360px, 440px) 180px; + gap: 16px; align-items: center; } @@ -105,7 +106,7 @@ pre { min-width: 0; display: grid; grid-template-columns: auto minmax(0, 1fr); - gap: 10px; + gap: 12px; align-items: center; } @@ -116,15 +117,15 @@ pre { input, select { width: 100%; - min-height: 42px; + min-height: 48px; box-sizing: border-box; border: 1px solid rgba(22, 29, 23, 0.24); border-radius: 4px; background: #f8f8f6; color: var(--text-color); font-family: var(--regular-font); - font-size: 15px; - padding: 0 12px; + font-size: 16px; + padding: 0 14px; outline: none; } @@ -139,14 +140,14 @@ select:focus { align-items: center; justify-content: center; gap: 8px; - min-height: 42px; + min-height: 48px; border: 0; border-radius: 4px; font-family: var(--regular-font); font-size: 14px; font-weight: 700; cursor: pointer; - padding: 0 16px; + padding: 0 18px; white-space: nowrap; } @@ -167,7 +168,7 @@ select:focus { .secondary, .icon-button { - background: var(--btn-secondary); + background: var(--btn-secondary, #5C7060); color: var(--text-card-color); } @@ -186,19 +187,39 @@ select:focus { } .icon-button { - width: 42px; + width: 48px; padding: 0; } .icon-button.small { - width: 34px; - min-height: 34px; + width: 38px; + min-height: 38px; +} + +.sigil-preview { + width: 64px; + height: 64px; + box-sizing: border-box; + border: 1px solid rgba(22, 29, 23, 0.16); + border-radius: 8px; + background: var(--btn-secondary); + overflow: hidden; +} + +.sigil-preview.empty { + background: var(--btn-secondary, #5C7060); +} + +.sigil-preview :global(svg) { + display: block; + width: 64px; + height: 64px; } .state-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 0 18px; + gap: 8px 22px; } .state-row div { @@ -211,7 +232,8 @@ select:focus { .state-row strong, .state-row code { min-width: 0; - font-size: 12px; + font-family: var(--log-font); + font-size: 13px; overflow-wrap: anywhere; } @@ -227,7 +249,7 @@ select:focus { .wallet-status-row { color: rgba(49, 57, 51, 0.76); - font-size: 12px; + font-size: 13px; } .passphrase-field summary, @@ -239,7 +261,7 @@ select:focus { .credential-row { display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(180px, 340px); - gap: 10px; + gap: 16px; align-items: center; } @@ -266,7 +288,7 @@ select:focus { .operation-tabs { grid-template-columns: repeat(5, minmax(0, 1fr)); - margin-bottom: 18px; + margin-bottom: 26px; } .segmented button, @@ -293,13 +315,13 @@ select:focus { .operation-body { display: grid; - gap: 14px; + gap: 20px; } .operation-line { display: grid; grid-template-columns: minmax(280px, 420px) auto minmax(0, 1fr) auto; - gap: 10px; + gap: 16px; align-items: center; } @@ -315,7 +337,7 @@ select:focus { .pending-item { display: flex; align-items: center; - gap: 10px; + gap: 14px; } .button-row { @@ -325,7 +347,7 @@ select:focus { .output-row { justify-content: space-between; border-top: 1px solid rgba(22, 29, 23, 0.12); - padding-top: 12px; + padding-top: 18px; } .output-row code { @@ -339,10 +361,10 @@ select:focus { } pre { - max-height: 220px; + max-height: 260px; overflow: auto; margin: 0; - padding: 12px 0 0 0; + padding: 18px 0 0 0; border-top: 1px solid rgba(22, 29, 23, 0.12); white-space: pre-wrap; font-size: 12px; @@ -351,7 +373,7 @@ pre { .boot-row { flex-wrap: wrap; border-top: 1px solid rgba(22, 29, 23, 0.12); - padding-top: 12px; + padding-top: 18px; } .boot-row select { @@ -359,10 +381,10 @@ pre { } .check-row { - min-height: 42px; + min-height: 48px; color: rgba(49, 57, 51, 0.78); - font-size: 12px; - font-weight: 700; + font-size: 13px; + font-weight: 600; } .check-row input { @@ -379,13 +401,13 @@ pre { .advanced { border-top: 1px solid rgba(22, 29, 23, 0.12); - padding-top: 12px; + padding-top: 18px; } .passphrase-field { display: grid; grid-template-columns: auto minmax(0, 1fr); - gap: 10px; + gap: 12px; align-items: center; } @@ -394,7 +416,7 @@ pre { } .passphrase-field summary { - min-height: 42px; + min-height: 48px; display: flex; align-items: center; } @@ -411,8 +433,8 @@ pre { .success-line { border-left: 4px solid; font-family: var(--log-font); - font-size: 12px; - padding-left: 12px; + font-size: 13px; + padding-left: 14px; } .error-line { @@ -450,7 +472,7 @@ pre { } .pending-main strong { - font-size: 14px; + font-size: 15px; } .pending-main code, @@ -466,14 +488,14 @@ pre { .pending-status { color: rgba(49, 57, 51, 0.7); - font-size: 12px; - font-weight: 700; + font-size: 13px; + font-weight: 600; text-transform: uppercase; } @media (max-width: 760px) { .keys-panel { - padding: 22px; + padding: 28px 22px; } .top-row, @@ -485,6 +507,10 @@ pre { grid-template-columns: 1fr; } + .sigil-preview { + justify-self: start; + } + .operation-tabs { grid-template-columns: repeat(2, minmax(0, 1fr)); } diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index 000849655..b4b05bb97 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -3,6 +3,7 @@ import { wide } from '$lib/stores/display' import Password from './Password.svelte' import StarTram from './StarTram.svelte' + import Hermes from './Hermes.svelte' diff --git a/ui/src/routes/system/+page.svelte b/ui/src/routes/system/+page.svelte index 22b8ebb5a..4cf3ebca1 100644 --- a/ui/src/routes/system/+page.svelte +++ b/ui/src/routes/system/+page.svelte @@ -10,7 +10,6 @@ import SystemDetails from './SystemDetails.svelte' import Power from './Power.svelte' import Logs from './Logs.svelte' - import Penpai from './Penpai.svelte' import Support from './Support.svelte' import ConfigEditor from './ConfigEditor.svelte' @@ -27,7 +26,7 @@ {#if !$URBIT_MODE} {/if} - +
diff --git a/ui/src/routes/system/BugReportModal.svelte b/ui/src/routes/system/BugReportModal.svelte index 094ce4dee..e8184a3d7 100644 --- a/ui/src/routes/system/BugReportModal.svelte +++ b/ui/src/routes/system/BugReportModal.svelte @@ -8,13 +8,9 @@ let contact = '' let description = '' let cpuProfile = false - let penpaiSelect = false let all = false const selectedShips = new Set() - $: penpai = ($structure?.apps?.penpai?.info) || {} - $: penpaiAllowed = (penpai?.allowed) || false - $: urbits = ($structure?.urbits) || {} $: urbitKeys = Object.keys(urbits) @@ -84,16 +80,6 @@ {/if}

Addtional Information

- {#if penpaiAllowed} -
penpaiSelect=!penpaiSelect}> -
- {#if penpaiSelect} - checkmark - {/if} -
-
Send Penpai Container Logs
-
- {/if}
cpuProfile=!cpuProfile}>
{#if cpuProfile} @@ -108,7 +94,7 @@ {:else} + + {#if open} + {#if $URBIT_MODE} +
Config editor is only available from GroundSeg.
+ {:else} +
+ + + + +
+ + {#if loading} +
Loading config...
+ {/if} + {#if error} +
{error}
+ {:else if validationError} +
{validationError}
+ {:else if status} +
{status}
+ {:else if dirty} +
Unsaved edits
+ {/if} + +