From 662888bab9d50ca4e7e0c94e88acd7ee83a27860 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 23 Jul 2025 16:11:41 -0700 Subject: [PATCH] Devkit avs release now supports setting metadata uri and publish now uses eigen runtime spec. --- .github/workflows/e2e.yml | 22 +- README.md | 27 +- config/templates.yaml | 2 +- go.mod | 9 +- go.sum | 14 +- pkg/commands/release.go | 389 +--------------- pkg/commands/release_actions.go | 569 +++++++++++++++++++++++ pkg/common/artifact/README.md | 148 ++++++ pkg/common/artifact/oci_artifact.go | 292 ++++++++++++ pkg/common/artifact/oci_artifact_test.go | 411 ++++++++++++++++ pkg/common/contract_caller.go | 50 +- pkg/template/config_test.go | 2 +- 12 files changed, 1554 insertions(+), 381 deletions(-) create mode 100644 pkg/commands/release_actions.go create mode 100644 pkg/common/artifact/README.md create mode 100644 pkg/common/artifact/oci_artifact.go create mode 100644 pkg/common/artifact/oci_artifact_test.go diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fc1b9b08..a059ec13 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -49,7 +49,7 @@ jobs: - name: Run devkit avs build env: - DOCKER_BUILDKIT: 1 + DOCKER_BUILDKIT: 1 run: | cd ./my-awesome-avs/ devkit avs build @@ -92,7 +92,25 @@ jobs: cd ./my-awesome-avs/ devkit avs transport verify + - name: Set release metadata URI + run: | + cd ./my-awesome-avs/ + + # Set URI for operator set 0 + devkit avs release uri \ + --metadata-uri "https://github.com/example/metadata-0.json" \ + --operator-set-id 0 + + echo "✅ Set metadata URI for operator set 0" + + # Set URI for operator set 1 + devkit avs release uri \ + --metadata-uri "https://github.com/example/metadata-1.json" \ + --operator-set-id 1 + + echo "✅ Set metadata URI for operator set 1" + - name: Stop devnet run: | cd ./my-awesome-avs/ - devkit avs devnet stop + devkit avs devnet stop \ No newline at end of file diff --git a/README.md b/README.md index cdc26ad1..d0f34f41 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,32 @@ Publishes your AVS release to the EigenLayer ReleaseManager contract, making it Before publishing a release, ensure you have: 1. Built your AVS with `devkit avs build` 2. A running devnet -3. Properly configured registry in your context +3. Properly configured registry in your context (or specify the command parameter) +4. **Set release metadata URI** for your operator sets (see below) + +> [!IMPORTANT] +> You must set a release metadata URI before publishing releases. The metadata URI provides important information about your release to operators. + +#### Setting Release Metadata URI + +Before publishing a release, set the metadata URI for your operator sets: + +```bash +# Set metadata URI for operator set 0 +devkit avs release uri --metadata-uri "https://example.com/metadata.json" --operator-set-id 0 + +# Set metadata URI for operator set 1 +devkit avs release uri --metadata-uri "https://example.com/metadata.json" --operator-set-id 1 +``` + +**Required Flags:** +- `--metadata-uri`: The URI pointing to your release metadata +- `--operator-set-id`: The operator set ID to configure + +**Optional Flags:** +- `--avs-address`: AVS address (uses context if not provided) + +#### Publishing a Release Run this from your project directory: > [!IMPORTANT] diff --git a/config/templates.yaml b/config/templates.yaml index 1e292c2f..4466008d 100644 --- a/config/templates.yaml +++ b/config/templates.yaml @@ -3,7 +3,7 @@ architectures: languages: go: baseUrl: "https://github.com/Layr-Labs/hourglass-avs-template" - version: "v0.0.14" + version: "v0.0.16" ts: baseUrl: "" version: "" diff --git a/go.mod b/go.mod index c3e9a8a0..09198138 100644 --- a/go.mod +++ b/go.mod @@ -12,11 +12,14 @@ require ( github.com/Layr-Labs/multichain-go v0.0.8 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/opencontainers/go-digest v1.0.0 + github.com/opencontainers/image-spec v1.1.1 github.com/posthog/posthog-go v1.4.10 github.com/robfig/cron/v3 v3.0.1 github.com/urfave/cli/v2 v2.27.6 golang.org/x/text v0.26.0 gopkg.in/yaml.v3 v3.0.1 + oras.land/oras-go/v2 v2.3.1 sigs.k8s.io/yaml v1.4.0 ) @@ -39,6 +42,7 @@ require ( github.com/deckarep/golang-set/v2 v2.8.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -54,6 +58,7 @@ require ( github.com/holiman/uint256 v1.3.2 // indirect github.com/iden3/go-iden3-crypto v0.0.17 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/klauspost/compress v1.16.5 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -63,12 +68,11 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect - github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/supranational/blst v0.3.15 // indirect github.com/tklauser/go-sysconf v0.3.15 // indirect github.com/tklauser/numcpus v0.10.0 // indirect @@ -93,6 +97,7 @@ require ( github.com/Layr-Labs/crypto-libs v0.0.4 github.com/charmbracelet/bubbletea v1.3.5 github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect + github.com/docker/cli v24.0.7+incompatible github.com/docker/docker v28.1.1+incompatible github.com/ethereum/go-ethereum v1.15.11 github.com/russross/blackfriday/v2 v2.1.0 // indirect diff --git a/go.sum b/go.sum index dfefda7e..cf47cd26 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,7 @@ github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwz github.com/crate-crypto/go-kzg-4844 v1.1.0 h1:EN/u9k2TF6OWSHrCCDBBU6GLNMq88OspHHlMnHfoyU4= github.com/crate-crypto/go-kzg-4844 v1.1.0/go.mod h1:JolLjpSff1tCCJKaJx4psrlEdlXuJEC996PL3tTAFks= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= @@ -77,8 +78,12 @@ github.com/deepmap/oapi-codegen v1.6.0 h1:w/d1ntwh91XI0b/8ja7+u5SvA4IFfM0UNNLmiD github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v24.0.7+incompatible h1:wa/nIwYFW7BVTGa7SWPVyyXU9lgORqUb1xfI36MSkFg= +github.com/docker/cli v24.0.7+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I= github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= +github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= @@ -160,8 +165,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= -github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= +github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -257,6 +262,7 @@ github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMT github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/supranational/blst v0.3.15 h1:rd9viN6tfARE5wv3KZJ9H8e1cg0jXW8syFCcsbHa76o= @@ -329,6 +335,7 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= @@ -365,9 +372,12 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +oras.land/oras-go/v2 v2.3.1 h1:lUC6q8RkeRReANEERLfH86iwGn55lbSWP20egdFHVec= +oras.land/oras-go/v2 v2.3.1/go.mod h1:5AQXVEu1X/FKp1F9DMOb5ZItZBOa0y5dha0yCm4NR9c= sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/pkg/commands/release.go b/pkg/commands/release.go index 8695bdfe..27420aad 100644 --- a/pkg/commands/release.go +++ b/pkg/commands/release.go @@ -1,117 +1,15 @@ package commands import ( - "context" - "encoding/hex" - "encoding/json" - "fmt" - "math/big" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "time" - "github.com/Layr-Labs/devkit-cli/pkg/common" - "github.com/Layr-Labs/devkit-cli/pkg/common/devnet" - "github.com/Layr-Labs/devkit-cli/pkg/common/iface" - releasemanager "github.com/Layr-Labs/eigenlayer-contracts/pkg/bindings/ReleaseManager" - ethcommon "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/ethclient" "github.com/urfave/cli/v2" - "gopkg.in/yaml.v3" ) // OperatorSetRelease represents the data for each operator set type OperatorSetRelease struct { - Digest string `json:"digest"` - Registry string `json:"registry"` -} - -// parseOperatorSetMapping parses the JSON output from the release script -func parseOperatorSetMapping(jsonOutput string) (map[string][]OperatorSetRelease, error) { - // Parse the JSON structure: {"0": [{"digest": "...", "registry": "..."}], "1": [...]} - var releases map[string][]OperatorSetRelease - if err := json.Unmarshal([]byte(jsonOutput), &releases); err != nil { - return nil, fmt.Errorf("failed to unmarshal operator set mapping: %w", err) - } - - // Validate that each operator set has at least one artifact - for opSetId, dataArray := range releases { - if len(dataArray) == 0 { - return nil, fmt.Errorf("operator set %s has empty data array", opSetId) - } - } - - return releases, nil -} - -// updateContextWithDigest updates the context YAML file with the digest after successful release -func updateContextWithDigest(digest string) error { - // Load the context yaml file - contextPath := filepath.Join("config", "contexts", "devnet.yaml") // TODO: make context configurable - contextNode, err := common.LoadYAML(contextPath) - if err != nil { - return fmt.Errorf("failed to load context yaml: %w", err) - } - - // Get the root node (first content node) - rootNode := contextNode.Content[0] - - // Get the context section - contextSection := common.GetChildByKey(rootNode, "context") - if contextSection == nil { - return fmt.Errorf("context section not found in yaml") - } - - // Get or create artifacts section - artifactsSection := common.GetChildByKey(contextSection, "artifact") - if artifactsSection == nil { - return fmt.Errorf("artifact section not found in context") - } - - // Update digest field - common.SetMappingValue(artifactsSection, - &yaml.Node{Kind: yaml.ScalarNode, Value: "digest"}, - &yaml.Node{Kind: yaml.ScalarNode, Value: digest}) - - // Write the updated yaml back to file - if err := common.WriteYAML(contextPath, contextNode); err != nil { - return fmt.Errorf("failed to write updated yaml: %w", err) - } - - return nil -} - -// updateContextWithVersion updates the context YAML file with the new version -func updateContextWithVersion(version string) error { - // Load the context yaml file - yamlPath, rootNode, contextNode, err := common.LoadContext(devnet.DEVNET_CONTEXT) - if err != nil { - return err - } - - // Get or create artifact section - artifactSection := common.GetChildByKey(contextNode, "artifact") - if artifactSection == nil { - artifactSection = &yaml.Node{Kind: yaml.MappingNode} - common.SetMappingValue(contextNode, - &yaml.Node{Kind: yaml.ScalarNode, Value: "artifact"}, - artifactSection) - } - - // Update version field - common.SetMappingValue(artifactSection, - &yaml.Node{Kind: yaml.ScalarNode, Value: "version"}, - &yaml.Node{Kind: yaml.ScalarNode, Value: version}) - - // Write the updated yaml back to file - if err := common.WriteYAML(yamlPath, rootNode); err != nil { - return fmt.Errorf("failed to write updated yaml: %w", err) - } - - return nil + Digest string `json:"digest"` + Registry string `json:"registry"` + RuntimeSpec string `json:"runtimeSpec,omitempty"` // YAML content of the runtime spec } // ReleaseCommand defines the "release" command @@ -135,265 +33,26 @@ var ReleaseCommand = &cli.Command{ }...), Action: publishReleaseAction, }, + { + Name: "uri", + Usage: "Set release metadata URI for an operator set", + Flags: append(common.GlobalFlags, []cli.Flag{ + &cli.StringFlag{ + Name: "metadata-uri", + Usage: "Metadata URI to set for the release", + Required: true, + }, + &cli.UintFlag{ + Name: "operator-set-id", + Usage: "Operator set ID", + Required: true, + }, + &cli.StringFlag{ + Name: "avs-address", + Usage: "AVS address (if not provided, will use from context)", + }, + }...), + Action: setReleaseMetadataURIAction, + }, }, } - -// processOperatorSets processes each operator set and publishes releases on chain -func processOperatorSetsAndPublishReleaseOnChain(cCtx *cli.Context, logger iface.Logger, operatorSetMapping map[string][]OperatorSetRelease, avs string, upgradeByTime int64, registry string) error { - // Publish releases for each operator set - for opSetId, opSetDataArray := range operatorSetMapping { - opSetIdInt, err := strconv.ParseUint(opSetId, 10, 32) - if err != nil { - logger.Warn("Failed to parse operator set ID %s: %v", opSetId, err) - continue - } - - logger.Info("Processing operator set %s with %d artifacts:", opSetId, len(opSetDataArray)) - - // Create artifacts array for this operator set - var artifacts []releasemanager.IReleaseManagerTypesArtifact - for i, opSetData := range opSetDataArray { - logger.Info("Artifact %d:", i+1) - logger.Info("Digest: %s", opSetData.Digest) - logger.Info("Registry: %s", opSetData.Registry) - - // this means this is the component - if opSetData.Registry == registry { - err := updateContextWithDigest(opSetData.Digest) - if err != nil { - logger.Warn("Failed to update context with digest for operator set %s artifact %d: %v", opSetId, i+1, err) - continue - } - logger.Info("Successfully updated context with digest for operator set %s artifact %d", opSetId, i+1) - } - - // Convert digest to bytes32 - digestBytes, err := hexStringToBytes32(opSetData.Digest) - if err != nil { - logger.Warn("Failed to convert digest to bytes32 for operator set %s artifact %d: %v", opSetId, i+1, err) - continue - } - - artifact := releasemanager.IReleaseManagerTypesArtifact{ - Digest: digestBytes, - Registry: opSetData.Registry, - } - artifacts = append(artifacts, artifact) - } - - if len(artifacts) == 0 { - logger.Warn("No valid artifacts for operator set %s, skipping", opSetId) - continue - } - - logger.Info("Publishing release for operator set %s with %d artifacts...", opSetId, len(artifacts)) - if err := publishReleaseToReleaseManagerAction(cCtx.Context, logger, avs, uint32(opSetIdInt), upgradeByTime, artifacts); err != nil { - if strings.Contains(err.Error(), "connection refused") { - logger.Warn("Failed to publish release for operator set %s: %v", opSetId, err) - logger.Info("Check if devnet is running and try again") - return err - } - } - logger.Info("Successfully published release for operator set %s", opSetId) - } - - return nil -} - -func publishReleaseAction(cCtx *cli.Context) error { - logger := common.LoggerFromContext(cCtx.Context) - - // Get values from flags - upgradeByTime := cCtx.Int64("upgrade-by-time") - registry := cCtx.String("registry") - - // Get build artifact from context first to read registry URL and version - cfg, err := common.LoadConfigWithContextConfig(devnet.DEVNET_CONTEXT) // TODO: make context configurable - if err != nil { - return fmt.Errorf("failed to load context config: %w", err) - } - - if cfg.Context[devnet.DEVNET_CONTEXT].Artifact == nil { - return fmt.Errorf("no artifact found in context. Please run 'devkit avs build' first") - } - - artifact := cfg.Context[devnet.DEVNET_CONTEXT].Artifact - avs := cfg.Context[devnet.DEVNET_CONTEXT].Avs.Address - // Validate AVS address - if avs == "" { - return fmt.Errorf("AVS addressempty in context") - } - - version := artifact.Version - // first time publishing, version is empty - if version == "" { - version = "0" - } - - // Validate upgradeByTime is in the future - if upgradeByTime <= time.Now().Unix() { - return fmt.Errorf("upgrade-by-time timestamp %d must be in the future (current time: %d)", upgradeByTime, time.Now().Unix()) - } - - if artifact.Component == "" { - return fmt.Errorf("no component found in context. Please run 'devkit avs build' first") - } - - logger.Info("Publishing AVS release...") - logger.Info("AVS address: %s", avs) - logger.Info("Version: %s", version) - logger.Info("Registry: %s", registry) - logger.Info("UpgradeByTime: %s", time.Unix(upgradeByTime, 0).Format(time.RFC3339)) - - // Call release.sh script to check if image has changed - scriptsDir := filepath.Join(".hourglass", "scripts") - releaseScriptPath := filepath.Join(scriptsDir, "release.sh") - - // Get registry from flag or context - finalRegistry := registry - if finalRegistry == "" { - if artifact.Registry == "" { - return fmt.Errorf("no registry found in context") - } - finalRegistry = artifact.Registry - logger.Info("Using registry from context: %s", finalRegistry) - } else { - logger.Info("Using provided registry: %s", finalRegistry) - } - component := cfg.Context[devnet.DEVNET_CONTEXT].Artifact.Component - // Execute release script with version and registry - releaseCmd := exec.CommandContext(cCtx.Context, "bash", releaseScriptPath, - "--version", version, - "--registry", finalRegistry, - "--image", component, - "--original-image-id", artifact.ArtifactId) - releaseCmd.Stderr = os.Stderr // Show stderr in terminal - - // Capture stdout to get the operator set mapping JSON - output, err := releaseCmd.Output() - if err != nil { - // Script returned non-zero exit code, meaning image has changed - logger.Info("Image has changed since last build. Please ensure your build is stable before releasing.") - logger.Info("Run 'devkit avs build' again and verify no code changes were made.") - return err - } - - // update version in context, by incrementing it - version, err = incrementVersion(version) - if err != nil { - return fmt.Errorf("failed to increment version: %w", err) - } - - // Update version in context - if err := updateContextWithVersion(version); err != nil { - return fmt.Errorf("failed to update context with version: %w", err) - } - - // Parse the operator set mapping JSON from script output - logger.Info("Processing operator set mapping from script output...") - operatorSetMapping, err := parseOperatorSetMapping(string(output)) - if err != nil { - logger.Warn("Failed to parse operator set mapping in hourglass release script: %v", err) - return err - } - - logger.Info("Retrieved operator set mapping with %d operator sets", len(operatorSetMapping)) - - // Publish releases for each operator set - if err := processOperatorSetsAndPublishReleaseOnChain(cCtx, logger, operatorSetMapping, avs, upgradeByTime, finalRegistry); err != nil { - return err - } - - return nil -} - -func incrementVersion(version string) (string, error) { - // version is a int - versionInt, err := strconv.Atoi(version) - if err != nil { - return "", fmt.Errorf("failed to convert version to int: %w", err) - } - versionInt++ - return strconv.Itoa(versionInt), nil -} - -func publishReleaseToReleaseManagerAction(ctx context.Context, logger iface.Logger, avs string, operatorSetId uint32, upgradeByTime int64, artifacts []releasemanager.IReleaseManagerTypesArtifact) error { - - cfg, err := common.LoadConfigWithContextConfig(devnet.DEVNET_CONTEXT) - if err != nil { - return fmt.Errorf("failed to load configurations for operator registration: %w", err) - } - envCtx, ok := cfg.Context[devnet.DEVNET_CONTEXT] - if !ok { - return fmt.Errorf("context '%s' not found in configuration", devnet.DEVNET_CONTEXT) - } - - l1Cfg, ok := envCtx.Chains[devnet.L1] - if !ok { - return fmt.Errorf("failed to get l1 chain config for context '%s'", devnet.DEVNET_CONTEXT) - } - - client, err := ethclient.Dial(l1Cfg.RPCURL) - if err != nil { - return fmt.Errorf("failed to connect to L1 RPC: %w", err) - } - defer client.Close() - - operatorSetId = uint32(operatorSetId) - upgradeByTime = int64(upgradeByTime) - - avsPrivateKey := envCtx.Avs.AVSPrivateKey - if avsPrivateKey == "" { - return fmt.Errorf("AVS private key not found in context") - } - // Trim 0x - avsPrivateKey = strings.TrimPrefix(avsPrivateKey, "0x") - _, _, _, _, _, _, releaseManagerAddress := devnet.GetEigenLayerAddresses(cfg) - - contractCaller, err := common.NewContractCaller( - avsPrivateKey, - big.NewInt(int64(l1Cfg.ChainID)), - client, - ethcommon.HexToAddress(""), - ethcommon.HexToAddress(""), - ethcommon.HexToAddress(""), - ethcommon.HexToAddress(""), - ethcommon.HexToAddress(""), - ethcommon.HexToAddress(releaseManagerAddress), - logger, - ) - if err != nil { - return fmt.Errorf("failed to create contract caller: %w", err) - } - - // Use the artifacts array passed in - err = contractCaller.PublishRelease(ctx, ethcommon.HexToAddress(avs), artifacts, operatorSetId, upgradeByTime) - if err != nil { - return fmt.Errorf("failed to publish release: %w", err) - } - - logger.Info("Successfully published release to ReleaseManager contract") - return nil -} - -// hexStringToBytes32 converts a hex string (like "sha256:abc123...") to [32]byte -func hexStringToBytes32(hexStr string) ([32]byte, error) { - var result [32]byte - - // Remove "sha256:" prefix if present - hexStr = strings.TrimPrefix(hexStr, "sha256:") - - // Decode hex string - bytes, err := hex.DecodeString(hexStr) - if err != nil { - return result, fmt.Errorf("failed to decode hex string: %w", err) - } - - // Ensure we have exactly 32 bytes - if len(bytes) != 32 { - return result, fmt.Errorf("digest must be exactly 32 bytes, got %d", len(bytes)) - } - - copy(result[:], bytes) - return result, nil -} diff --git a/pkg/commands/release_actions.go b/pkg/commands/release_actions.go new file mode 100644 index 00000000..601e8d7c --- /dev/null +++ b/pkg/commands/release_actions.go @@ -0,0 +1,569 @@ +package commands + +import ( + "context" + "encoding/hex" + "encoding/json" + "fmt" + "math/big" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Layr-Labs/devkit-cli/pkg/common" + "github.com/Layr-Labs/devkit-cli/pkg/common/artifact" + "github.com/Layr-Labs/devkit-cli/pkg/common/devnet" + "github.com/Layr-Labs/devkit-cli/pkg/common/iface" + releasemanager "github.com/Layr-Labs/eigenlayer-contracts/pkg/bindings/ReleaseManager" + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/urfave/cli/v2" + "gopkg.in/yaml.v3" +) + +func publishReleaseAction(cCtx *cli.Context) error { + logger := common.LoggerFromContext(cCtx.Context) + + // Get values from flags + upgradeByTime := cCtx.Int64("upgrade-by-time") + registry := cCtx.String("registry") + + // Get build artifact from context first to read registry URL and version + cfg, err := common.LoadConfigWithContextConfig(devnet.DEVNET_CONTEXT) // TODO: make context configurable + if err != nil { + return fmt.Errorf("failed to load context config: %w", err) + } + + if cfg.Context[devnet.DEVNET_CONTEXT].Artifact == nil { + return fmt.Errorf("no artifact found in context. Please run 'devkit avs build' first") + } + + artifact := cfg.Context[devnet.DEVNET_CONTEXT].Artifact + avs := cfg.Context[devnet.DEVNET_CONTEXT].Avs.Address + // Validate AVS address + if avs == "" { + return fmt.Errorf("AVS addressempty in context") + } + + // Check if metadata URI is set for any operator set before proceeding + logger.Info("Checking AVS metadata URI...") + if err := checkMetadataURIExists(logger, cfg, avs); err != nil { + return err + } + + version := artifact.Version + // first time publishing, version is empty + if version == "" { + version = "0" + } + + // Validate upgradeByTime is in the future + if upgradeByTime <= time.Now().Unix() { + return fmt.Errorf("upgrade-by-time timestamp %d must be in the future (current time: %d)", upgradeByTime, time.Now().Unix()) + } + + if artifact.Component == "" { + return fmt.Errorf("no artifact found to release. Please run 'devkit avs build' first") + } + + logger.Info("Publishing AVS release...") + logger.Info("AVS address: %s", avs) + logger.Info("Version: %s", version) + logger.Info("Registry: %s", registry) + logger.Info("UpgradeByTime: %s", time.Unix(upgradeByTime, 0).Format(time.RFC3339)) + + // Call release.sh script to check if image has changed + scriptsDir := filepath.Join(".devkit", "scripts") + releaseScriptPath := filepath.Join(scriptsDir, "release") + + // Get registry from flag or context + finalRegistry := registry + if finalRegistry == "" { + if artifact.Registry == "" { + return fmt.Errorf("no registry found in context") + } + finalRegistry = artifact.Registry + logger.Info("Using registry from context: %s", finalRegistry) + } else { + logger.Info("Using provided registry: %s", finalRegistry) + } + component := cfg.Context[devnet.DEVNET_CONTEXT].Artifact.Component + // Execute release script with version and registry + releaseCmd := exec.CommandContext(cCtx.Context, "bash", releaseScriptPath, + "--version", version, + "--registry", finalRegistry, + "--image", component) + releaseCmd.Stderr = os.Stderr + + // Add environment variable for context + releaseCmd.Env = append(os.Environ(), fmt.Sprintf("CONTEXT_NAME=%s", devnet.DEVNET_CONTEXT)) + + // Capture stdout to get the operator set mapping JSON + output, err := releaseCmd.Output() + if err != nil { + // Script returned non-zero exit code, meaning image has changed + return fmt.Errorf("failed to release artifact: %w", err) + } + + // Parse the operator set mapping JSON from script output + logger.Info("Processing operator set mapping from script output...") + operatorSetMapping, err := parseOperatorSetMapping(string(output)) + if err != nil { + logger.Warn("Failed to parse operator set mapping in hourglass release script: %v", err) + return err + } + + logger.Info("Retrieved operator set mapping with %d operator sets", len(operatorSetMapping)) + + // Publish releases for each operator set + if err := processOperatorSetsAndPublishReleaseOnChain(cCtx, logger, operatorSetMapping, avs, upgradeByTime, finalRegistry, version); err != nil { + return err + } + + // Only increment version after successful publishing + newVersion, err := incrementVersion(version) + if err != nil { + return fmt.Errorf("failed to increment version: %w", err) + } + + // Update version in context + if err := updateContextWithVersion(newVersion); err != nil { + return fmt.Errorf("failed to update context with version: %w", err) + } + + logger.Info("Successfully published release and incremented version to %s", newVersion) + + return nil +} + +// processOperatorSets processes each operator set and publishes releases on chain +func processOperatorSetsAndPublishReleaseOnChain( + cCtx *cli.Context, + logger iface.Logger, + operatorSetMapping map[string]OperatorSetRelease, + avs string, + upgradeByTime int64, + registry string, + version string, +) error { + // Create OCI artifact builder + ociBuilder := artifact.NewOCIArtifactBuilder(logger) + + // Get AVS name from context for artifact naming + cfg, err := common.LoadConfigWithContextConfig(devnet.DEVNET_CONTEXT) + if err != nil { + return fmt.Errorf("failed to load context config: %w", err) + } + + // Get AVS name from project configuration + avsName := cfg.Config.Project.Name + if avsName == "" { + return fmt.Errorf("project name not found in config.yaml. Please ensure config.project.name is set") + } + + // Publish releases for each operator set + for opSetId, opSetData := range operatorSetMapping { + opSetIdInt, err := strconv.ParseUint(opSetId, 10, 32) + if err != nil { + return fmt.Errorf("failed to parse operator set ID %s: %v", opSetId, err) + } + + logger.Info("Processing operator set %s", opSetId) + logger.Info("Digest: %s", opSetData.Digest) + logger.Info("Registry: %s", opSetData.Registry) + + // Create OCI artifact for runtime spec + logger.Info("Creating OCI artifact for runtime spec...") + artifactTag := fmt.Sprintf("opset-%s-v%s", opSetId, version) + + // Create and push OCI artifact + ociDigest, err := ociBuilder.CreateEigenRuntimeArtifact( + []byte(opSetData.RuntimeSpec), + registry, + avsName, + artifactTag, + ) + if err != nil { + logger.Error("Failed to create OCI artifact for operator set %s: %v", opSetId, err) + return fmt.Errorf("failed to create OCI artifact: %w", err) + } + + finalDigest := ociDigest + finalRegistry := registry + logger.Info("Successfully created OCI artifact with digest: %s", finalDigest) + + // Update context with digest + err = updateContextWithDigest(finalDigest) + if err != nil { + return fmt.Errorf("failed to update context with digest for operator set %s: %v", opSetId, err) + } + logger.Info("Successfully updated context with digest for operator set %s", opSetId) + + // Convert digest to bytes32 + digestBytes, err := hexStringToBytes32(finalDigest) + if err != nil { + logger.Warn("Failed to convert digest to bytes32 for operator set %s: %v", opSetId, err) + continue + } + + // Create artifact for this operator set + artifact := releasemanager.IReleaseManagerTypesArtifact{ + Digest: digestBytes, + Registry: finalRegistry, + } + artifacts := []releasemanager.IReleaseManagerTypesArtifact{artifact} + + logger.Info("Publishing release for operator set %s...", opSetId) + if err := publishReleaseToReleaseManagerAction(cCtx.Context, logger, avs, uint32(opSetIdInt), upgradeByTime, artifacts); err != nil { + if strings.Contains(err.Error(), "connection refused") { + logger.Warn("Failed to publish release for operator set %s: %v", opSetId, err) + logger.Info("Check if devnet is running and try again") + } + return err + } + logger.Info("Successfully published release for operator set %s", opSetId) + } + + return nil +} + +func incrementVersion(version string) (string, error) { + // version is a int + versionInt, err := strconv.Atoi(version) + if err != nil { + return "", fmt.Errorf("failed to convert version to int: %w", err) + } + versionInt++ + return strconv.Itoa(versionInt), nil +} + +// checkMetadataURIExists checks if metadata URI is set for at least one operator set +func checkMetadataURIExists(logger iface.Logger, cfg *common.ConfigWithContextConfig, avsAddress string) error { + // Get L1 chain config + envCtx, ok := cfg.Context[devnet.DEVNET_CONTEXT] + if !ok { + return fmt.Errorf("context '%s' not found in configuration", devnet.DEVNET_CONTEXT) + } + + l1Cfg, ok := envCtx.Chains[devnet.L1] + if !ok { + return fmt.Errorf("failed to get l1 chain config for context '%s'", devnet.DEVNET_CONTEXT) + } + + // Connect to L1 + client, err := ethclient.Dial(l1Cfg.RPCURL) + if err != nil { + return fmt.Errorf("failed to connect to L1 RPC: %w", err) + } + defer client.Close() + + // Get AVS private key + avsPrivateKey := envCtx.Avs.AVSPrivateKey + if avsPrivateKey == "" { + return fmt.Errorf("AVS private key not found in context") + } + avsPrivateKey = strings.TrimPrefix(avsPrivateKey, "0x") + + // Get contract addresses + _, _, _, _, _, _, releaseManagerAddress := devnet.GetEigenLayerAddresses(cfg) + + // Create contract caller + contractCaller, err := common.NewContractCaller( + avsPrivateKey, + big.NewInt(int64(l1Cfg.ChainID)), + client, + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(releaseManagerAddress), + logger, + ) + if err != nil { + return fmt.Errorf("failed to create contract caller: %w", err) + } + + // Check metadata URI for common operator sets (0 and 1) + metadataFound := false + operatorSetsToCheck := []uint32{0, 1} + + for _, opSetId := range operatorSetsToCheck { + uri, err := contractCaller.GetReleaseMetadataUri(ethcommon.HexToAddress(avsAddress), opSetId) + if err != nil { + logger.Debug("Error checking metadata URI for operator set %d: %v", opSetId, err) + continue + } + if uri != "" { + logger.Info("Found metadata URI for operator set %d: %s", opSetId, uri) + metadataFound = true + } + } + + if !metadataFound { + return fmt.Errorf("no release metadata URI found for AVS %s. Please set metadata URI using:\n devkit avs release uri --metadata-uri --operator-set-id ", avsAddress) + } + + return nil +} + +func publishReleaseToReleaseManagerAction( + ctx context.Context, + logger iface.Logger, + avs string, + operatorSetId uint32, + upgradeByTime int64, + artifacts []releasemanager.IReleaseManagerTypesArtifact, +) error { + + cfg, err := common.LoadConfigWithContextConfig(devnet.DEVNET_CONTEXT) + if err != nil { + return fmt.Errorf("failed to load configurations for operator registration: %w", err) + } + envCtx, ok := cfg.Context[devnet.DEVNET_CONTEXT] + if !ok { + return fmt.Errorf("context '%s' not found in configuration", devnet.DEVNET_CONTEXT) + } + + l1Cfg, ok := envCtx.Chains[devnet.L1] + if !ok { + return fmt.Errorf("failed to get l1 chain config for context '%s'", devnet.DEVNET_CONTEXT) + } + + client, err := ethclient.Dial(l1Cfg.RPCURL) + if err != nil { + return fmt.Errorf("failed to connect to L1 RPC: %w", err) + } + defer client.Close() + + avsPrivateKey := envCtx.Avs.AVSPrivateKey + if avsPrivateKey == "" { + return fmt.Errorf("AVS private key not found in context") + } + // Trim 0x + avsPrivateKey = strings.TrimPrefix(avsPrivateKey, "0x") + _, _, _, _, _, _, releaseManagerAddress := devnet.GetEigenLayerAddresses(cfg) + + contractCaller, err := common.NewContractCaller( + avsPrivateKey, + big.NewInt(int64(l1Cfg.ChainID)), + client, + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(releaseManagerAddress), + logger, + ) + if err != nil { + return fmt.Errorf("failed to create contract caller: %w", err) + } + logger.Info("Publishing operator set mapping from script output...") + err = contractCaller.PublishRelease(ctx, ethcommon.HexToAddress(avs), artifacts, operatorSetId, uint32(upgradeByTime)) + if err != nil { + return fmt.Errorf("failed to publish release: %w", err) + } + + logger.Info("Successfully published release to ReleaseManager contract") + return nil +} + +// setReleaseMetadataURIAction handles the "release uri" subcommand +func setReleaseMetadataURIAction(cCtx *cli.Context) error { + logger := common.LoggerFromContext(cCtx.Context) + + // Get values from flags + metadataURI := cCtx.String("metadata-uri") + operatorSetID := cCtx.Uint("operator-set-id") + avsAddressStr := cCtx.String("avs-address") + + // Load configuration + cfg, err := common.LoadConfigWithContextConfig(devnet.DEVNET_CONTEXT) // TODO: make context configurable + if err != nil { + return fmt.Errorf("failed to load context config: %w", err) + } + + // Get AVS address from flag or context + var avsAddress string + if avsAddressStr != "" { + avsAddress = avsAddressStr + } else { + avsAddress = cfg.Context[devnet.DEVNET_CONTEXT].Avs.Address + if avsAddress == "" { + return fmt.Errorf("AVS address not provided and not found in context") + } + } + + logger.Info("Setting release metadata URI...") + logger.Info("AVS address: %s", avsAddress) + logger.Info("Operator Set ID: %d", operatorSetID) + logger.Info("Metadata URI: %s", metadataURI) + + // Get L1 chain config + envCtx, ok := cfg.Context[devnet.DEVNET_CONTEXT] + if !ok { + return fmt.Errorf("context '%s' not found in configuration", devnet.DEVNET_CONTEXT) + } + + l1Cfg, ok := envCtx.Chains[devnet.L1] + if !ok { + return fmt.Errorf("failed to get l1 chain config for context '%s'", devnet.DEVNET_CONTEXT) + } + + // Connect to L1 + client, err := ethclient.Dial(l1Cfg.RPCURL) + if err != nil { + return fmt.Errorf("failed to connect to L1 RPC: %w", err) + } + defer client.Close() + + // Get AVS private key + avsPrivateKey := envCtx.Avs.AVSPrivateKey + if avsPrivateKey == "" { + return fmt.Errorf("AVS private key not found in context") + } + avsPrivateKey = strings.TrimPrefix(avsPrivateKey, "0x") + + // Get contract addresses + _, _, _, _, _, _, releaseManagerAddress := devnet.GetEigenLayerAddresses(cfg) + + // Create contract caller + contractCaller, err := common.NewContractCaller( + avsPrivateKey, + big.NewInt(int64(l1Cfg.ChainID)), + client, + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(""), + ethcommon.HexToAddress(releaseManagerAddress), + logger, + ) + if err != nil { + return fmt.Errorf("failed to create contract caller: %w", err) + } + + // Set release metadata URI + err = contractCaller.SetReleaseMetadata( + cCtx.Context, + metadataURI, + ethcommon.HexToAddress(avsAddress), + uint32(operatorSetID), + ) + if err != nil { + return fmt.Errorf("failed to set release metadata URI: %w", err) + } + + logger.Info("Successfully set release metadata URI for operator set %d", operatorSetID) + return nil +} + +// hexStringToBytes32 converts a hex string (like "sha256:abc123...") to [32]byte +func hexStringToBytes32(hexStr string) ([32]byte, error) { + var result [32]byte + + // Remove "sha256:" prefix if present + hexStr = strings.TrimPrefix(hexStr, "sha256:") + + // Decode hex string + bytes, err := hex.DecodeString(hexStr) + if err != nil { + return result, fmt.Errorf("failed to decode hex string: %w", err) + } + + // Ensure we have exactly 32 bytes + if len(bytes) != 32 { + return result, fmt.Errorf("digest must be exactly 32 bytes, got %d", len(bytes)) + } + + copy(result[:], bytes) + return result, nil +} + +// parseOperatorSetMapping parses the JSON output from the release script +func parseOperatorSetMapping(jsonOutput string) (map[string]OperatorSetRelease, error) { + // Parse the JSON structure: {"0": {"digest": "...", "registry": "...", "runtimeSpec": "..."}, "1": {...}} + var releases map[string]OperatorSetRelease + if err := json.Unmarshal([]byte(jsonOutput), &releases); err != nil { + return nil, fmt.Errorf("failed to unmarshal operator set mapping: %w", err) + } + + // Validate that we have at least one operator set + if len(releases) == 0 { + return nil, fmt.Errorf("no operator sets found in release output") + } + + return releases, nil +} + +// updateContextWithDigest updates the context YAML file with the digest after successful release +func updateContextWithDigest(digest string) error { + // Load the context yaml file + contextPath := filepath.Join("config", "contexts", "devnet.yaml") // TODO: make context configurable + contextNode, err := common.LoadYAML(contextPath) + if err != nil { + return fmt.Errorf("failed to load context yaml: %w", err) + } + + // Get the root node (first content node) + rootNode := contextNode.Content[0] + + // Get the context section + contextSection := common.GetChildByKey(rootNode, "context") + if contextSection == nil { + return fmt.Errorf("context section not found in yaml") + } + + // Get or create artifacts section + artifactsSection := common.GetChildByKey(contextSection, "artifact") + if artifactsSection == nil { + return fmt.Errorf("artifact section not found in context") + } + + // Update digest field + common.SetMappingValue(artifactsSection, + &yaml.Node{Kind: yaml.ScalarNode, Value: "digest"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: digest}) + + // Write the updated yaml back to file + if err := common.WriteYAML(contextPath, contextNode); err != nil { + return fmt.Errorf("failed to write updated yaml: %w", err) + } + + return nil +} + +// updateContextWithVersion updates the context YAML file with the new version +func updateContextWithVersion(version string) error { + // Load the context yaml file + yamlPath, rootNode, contextNode, err := common.LoadContext(devnet.DEVNET_CONTEXT) + if err != nil { + return err + } + + // Get or create artifact section + artifactSection := common.GetChildByKey(contextNode, "artifact") + if artifactSection == nil { + artifactSection = &yaml.Node{Kind: yaml.MappingNode} + common.SetMappingValue(contextNode, + &yaml.Node{Kind: yaml.ScalarNode, Value: "artifact"}, + artifactSection) + } + + // Update version field + common.SetMappingValue(artifactSection, + &yaml.Node{Kind: yaml.ScalarNode, Value: "version"}, + &yaml.Node{Kind: yaml.ScalarNode, Value: version}) + + // Write the updated yaml back to file + if err := common.WriteYAML(yamlPath, rootNode); err != nil { + return fmt.Errorf("failed to write updated yaml: %w", err) + } + + return nil +} diff --git a/pkg/common/artifact/README.md b/pkg/common/artifact/README.md new file mode 100644 index 00000000..ced8d7a3 --- /dev/null +++ b/pkg/common/artifact/README.md @@ -0,0 +1,148 @@ +# OCI Artifact Package + +This package provides functionality for creating and managing OCI (Open Container Initiative) artifacts specifically designed for EigenRuntime specifications. It uses the `oras-go` library to create standards-compliant OCI artifacts with custom media types. + +## Overview + +The `artifact` package enables DevKit to package EigenRuntime specifications as OCI artifacts, making them distributable through standard container registries like Docker Hub, GitHub Container Registry (GHCR), Amazon ECR, and others. + +## Key Features + +- **Standards-Compliant OCI Artifacts**: Creates artifacts that fully comply with the OCI specification +- **Custom Media Types**: Supports EigenRuntime-specific media types for configs and layers +- **Registry Authentication**: Integrates with Docker credential helpers for seamless authentication +- **In-Memory Construction**: Uses memory stores for efficient artifact building before pushing + +## Architecture + +### OCI Artifact Structure + +The package creates OCI artifacts with the following manifest structure: + +```json +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/vnd.eigenruntime.manifest.v1", + "config": { + "mediaType": "application/vnd.eigenruntime.manifest.config.v1+json", + "digest": "sha256:", + "size": + }, + "layers": [{ + "mediaType": "text/yaml", + "digest": "sha256:", + "size": + }], + "annotations": { + "org.opencontainers.image.source": "https://github.com/Layr-Labs/devkit-cli", + "org.opencontainers.image.description": "EigenRuntime specification for ", + "org.opencontainers.image.created": "", + "io.eigenruntime.spec.version": "v1" + } +} +``` + +### Config Blob Format + +The config blob contains metadata about the EigenRuntime specification: + +```json +{ + "formatVersion": "1.0", + "eigenRuntimeAPIVersion": "v1", + "kind": "template-type", + "avsName": "", + "validationSchema": "https://eigenruntime.io/schemas/template-type/v1/manifest.json", + "metadata": { + "createdAt": "2024-01-01T00:00:00Z", + "releaseVersion": "", + "devkitVersion": "" + } +} +``` + +## Authentication + +The package automatically uses Docker's credential helpers for authentication through `auth.DefaultClient`. This means: + +1. It will use credentials from `~/.docker/config.json` +2. It supports Docker credential helpers (e.g., `docker-credential-desktop`, `docker-credential-ecr-login`) +3. No manual credential configuration is needed if you're already logged in via `docker login` +4. The DOCKER_CONFIG environment variable is respected if set + +To authenticate with a registry, simply use `docker login` before running DevKit: +```bash +docker login ghcr.io +# or +docker login +``` + +## Troubleshooting + +## Inspecting OCI Artifacts + +**Important**: Don't use `docker pull` to inspect OCI artifacts, as Docker may transform the manifest for compatibility. Instead, use: + +### Using oras CLI: +```bash +# Fetch the manifest +oras manifest fetch ghcr.io/myorg/my-avs:opset-0-v1 + +# Pull the artifact locally +oras pull ghcr.io/myorg/my-avs:opset-0-v1 +``` + +### Using crane CLI: +```bash +# View the manifest +crane manifest ghcr.io/myorg/my-avs:opset-0-v1 | jq . + +# Export the artifact +crane export ghcr.io/myorg/my-avs:opset-0-v1 - | tar -tv +``` + +These tools preserve the original OCI artifact structure, including the `artifactType` field and proper media types. + +## Registry Compatibility + +All major registries support OCI artifacts with `artifactType`. However, some registry web UIs may display artifacts differently than their actual stored format: + +### Verifying Your Artifact + +To see the actual OCI artifact manifest stored in the registry: + +```bash +# Use oras to inspect the manifest +oras manifest fetch ghcr.io/myorg/my-avs:tag | jq . + +# Or use crane +crane manifest ghcr.io/myorg/my-avs:tag | jq . + +# Even docker shows the correct manifest +docker manifest inspect ghcr.io/myorg/my-avs:tag | jq . +``` + +```json +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/vnd.eigenruntime.manifest.v1", + "config": { + "mediaType": "application/vnd.eigenruntime.manifest.config.v1+json", + "digest": "sha256:...", + "size": 323 + }, + "layers": [{ + "mediaType": "text/yaml", + "digest": "sha256:...", + "size": 663 + }], + "annotations": { + "org.opencontainers.image.source": "https://github.com/Layr-Labs/devkit-cli", + "org.opencontainers.image.description": "EigenRuntime specification for ...", + "org.opencontainers.image.created": "2025-01-01T00:00:00Z", + "io.eigenruntime.spec.version": "v1" + } +} +``` \ No newline at end of file diff --git a/pkg/common/artifact/oci_artifact.go b/pkg/common/artifact/oci_artifact.go new file mode 100644 index 00000000..107e6871 --- /dev/null +++ b/pkg/common/artifact/oci_artifact.go @@ -0,0 +1,292 @@ +package artifact + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/Layr-Labs/devkit-cli/internal/version" + "github.com/Layr-Labs/devkit-cli/pkg/common/iface" + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/credentials" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/memory" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" +) + +// OCIArtifactBuilder creates OCI artifacts for EigenRuntime specs +type OCIArtifactBuilder struct { + logger iface.Logger +} + +// NewOCIArtifactBuilder creates a new OCI artifact builder +func NewOCIArtifactBuilder(logger iface.Logger) *OCIArtifactBuilder { + return &OCIArtifactBuilder{ + logger: logger, + } +} + +// CreateEigenRuntimeArtifact creates and pushes an OCI artifact containing the runtime spec +// Using oras-go for full OCI artifact support including custom media types and artifactType +// +// This implementation produces an OCI artifact with the following structure: +// +// { +// "schemaVersion": 2, +// "mediaType": "application/vnd.oci.image.manifest.v1+json", +// "artifactType": "application/vnd.eigenruntime.manifest.v1", +// "config": { +// "mediaType": "application/vnd.eigenruntime.manifest.config.v1+json", +// "digest": "sha256:", +// "size": +// }, +// "layers": [{ +// "mediaType": "text/yaml", +// "digest": "sha256:", +// "size": +// }], +// "annotations": { +// "org.opencontainers.image.source": "https://github.com/Layr-Labs/devkit-cli", +// "org.opencontainers.image.description": "EigenRuntime specification for ", +// "org.opencontainers.image.created": "", +// "io.eigenruntime.spec.version": "v1" +// } +// } +func (b *OCIArtifactBuilder) CreateEigenRuntimeArtifact( + runtimeSpec []byte, + registry string, + avsName string, + tag string, +) (string, error) { + ctx := context.Background() + + // Construct the full image reference + imageRef := fmt.Sprintf("%s:%s", registry, tag) + + b.logger.Info("Creating EigenRuntime OCI artifact for %s", imageRef) + + // Create an in-memory store for building the artifact + memStore := memory.New() + + // Create the config JSON + configContent := b.createConfigBlob(avsName, tag) + configMediaType := "application/vnd.eigenruntime.manifest.config.v1+json" + + // Add config to store + configDesc, err := b.addToStore(ctx, memStore, configMediaType, configContent) + if err != nil { + return "", fmt.Errorf("failed to add config to store: %w", err) + } + + // Add runtime spec layer to store + specMediaType := "text/yaml" + specDesc, err := b.addToStore(ctx, memStore, specMediaType, runtimeSpec) + if err != nil { + return "", fmt.Errorf("failed to add runtime spec to store: %w", err) + } + + // Create the manifest + manifest := ocispec.Manifest{ + MediaType: ocispec.MediaTypeImageManifest, + ArtifactType: "application/vnd.eigenruntime.manifest.v1", + Config: configDesc, + Layers: []ocispec.Descriptor{specDesc}, + Annotations: map[string]string{ + "org.opencontainers.image.source": "https://github.com/Layr-Labs/devkit-cli", + "org.opencontainers.image.description": "EigenRuntime specification for AVS " + avsName, + "org.opencontainers.image.created": time.Now().UTC().Format(time.RFC3339), + "io.eigenruntime.spec.version": "v1", + }, + } + + // Create a proper manifest with schemaVersion + // We need to manually construct the JSON to ensure artifactType is preserved + manifestMap := map[string]interface{}{ + "schemaVersion": 2, + "mediaType": manifest.MediaType, + "artifactType": manifest.ArtifactType, + "config": map[string]interface{}{ + "mediaType": manifest.Config.MediaType, + "digest": manifest.Config.Digest.String(), + "size": manifest.Config.Size, + }, + "layers": func() []map[string]interface{} { + layers := make([]map[string]interface{}, len(manifest.Layers)) + for i, layer := range manifest.Layers { + layers[i] = map[string]interface{}{ + "mediaType": layer.MediaType, + "digest": layer.Digest.String(), + "size": layer.Size, + } + } + return layers + }(), + "annotations": manifest.Annotations, + } + + // Marshal the manifest + manifestBytes, err := json.Marshal(manifestMap) + if err != nil { + return "", fmt.Errorf("failed to marshal manifest: %w", err) + } + + // Add manifest to store + manifestDesc, err := b.addToStore(ctx, memStore, ocispec.MediaTypeImageManifest, manifestBytes) + if err != nil { + return "", fmt.Errorf("failed to add manifest to store: %w", err) + } + + // Tag the manifest in the memory store so oras.Copy can find it + err = memStore.Tag(ctx, manifestDesc, tag) + if err != nil { + return "", fmt.Errorf("failed to tag manifest in memory store: %w", err) + } + + // Parse the repository reference + repo, err := remote.NewRepository(imageRef) + if err != nil { + return "", fmt.Errorf("failed to create repository: %w", err) + } + + // Set up authentication using Docker's credential store + repo.Client = &auth.Client{ + Cache: auth.DefaultCache, + Credential: func(ctx context.Context, reg string) (auth.Credential, error) { + // Try to load Docker config + dockerConfigDir := os.Getenv("DOCKER_CONFIG") + if dockerConfigDir == "" { + homeDir, _ := os.UserHomeDir() + dockerConfigDir = fmt.Sprintf("%s/.docker", homeDir) + } + + cfg, err := config.Load(dockerConfigDir) + if err != nil { + b.logger.Debug("Failed to load Docker config from %s: %v", dockerConfigDir, err) + // Return empty credentials for anonymous access + return auth.Credential{}, nil + } + + // Get the credentials store + store := credentials.NewNativeStore(cfg, cfg.CredentialsStore) + + // Try to get credentials for the registry + authConfig, err := store.Get(reg) + if err != nil { + b.logger.Debug("No credentials found for registry %s: %v", reg, err) + // Return empty credentials for anonymous access + return auth.Credential{}, nil + } + + // Convert to oras auth.Credential + cred := auth.Credential{ + Username: authConfig.Username, + Password: authConfig.Password, + } + + // Handle token-based auth (e.g., for Docker Hub) + if authConfig.IdentityToken != "" { + cred.RefreshToken = authConfig.IdentityToken + } + + return cred, nil + }, + } + + // Use HTTPS by default + repo.PlainHTTP = false + + // Push the artifact + b.logger.Info("Pushing EigenRuntime artifact to %s", imageRef) + + // Use oras.Copy to push the complete artifact graph from memory store to registry + // This preserves the artifactType and all custom media types in the manifest + // oras.Copy handles: + // - Walking the dependency graph from the manifest + // - Pushing all referenced blobs (config and layers) + // - Pushing the manifest itself with proper media type + // - Tagging the manifest in the registry + _, err = oras.Copy(ctx, memStore, tag, repo, tag, + oras.CopyOptions{ + CopyGraphOptions: oras.CopyGraphOptions{ + Concurrency: 3, + }, + }, + ) + if err != nil { + return "", fmt.Errorf("failed to push artifact: %w", err) + } + + digestStr := manifestDesc.Digest.String() + b.logger.Info("Successfully pushed EigenRuntime artifact with digest: %s", digestStr) + + return digestStr, nil +} + +// addToStore adds content to the memory store and returns its descriptor +func (b *OCIArtifactBuilder) addToStore(ctx context.Context, store *memory.Store, mediaType string, content []byte) (ocispec.Descriptor, error) { + // Calculate digest + d := digest.FromBytes(content) + + // Create descriptor + desc := ocispec.Descriptor{ + MediaType: mediaType, + Digest: d, + Size: int64(len(content)), + } + + // Push to store + err := store.Push(ctx, desc, bytes.NewReader(content)) + if err != nil { + return ocispec.Descriptor{}, err + } + + return desc, nil +} + +// createConfigBlob creates the OCI config JSON blob as specified in the TDD +func (b *OCIArtifactBuilder) createConfigBlob(name, tag string) []byte { + // TODO: parameterize to template. + c := map[string]interface{}{ + "formatVersion": "1.0", + "eigenRuntimeAPIVersion": "v1", + "kind": "Hourglass", + "avsName": name, + "validationSchema": "https://eigenruntime.io/schemas/hourglass/v1/manifest.json", + "metadata": map[string]string{ + "createdAt": time.Now().UTC().Format(time.RFC3339), + "releaseVersion": tag, + "devkitVersion": getDevkitVersion(), + }, + } + + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + // This should never happen with the static structure above + b.logger.Error("Failed to marshal config JSON: %v", err) + return []byte("{}") + } + + return data +} + +// getDevkitVersion returns the current DevKit version +func getDevkitVersion() string { + if version.Version != "" { + return version.Version + } + return "dev" +} + +// ComputeRuntimeSpecDigest computes the SHA256 digest of a runtime spec +func ComputeRuntimeSpecDigest(runtimeSpec []byte) string { + hash := sha256.Sum256(runtimeSpec) + return "sha256:" + hex.EncodeToString(hash[:]) +} diff --git a/pkg/common/artifact/oci_artifact_test.go b/pkg/common/artifact/oci_artifact_test.go new file mode 100644 index 00000000..fbade08a --- /dev/null +++ b/pkg/common/artifact/oci_artifact_test.go @@ -0,0 +1,411 @@ +package artifact + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/content/memory" +) + +// mockLogger implements iface.Logger for testing +type mockLogger struct { + messages []string +} + +func (m *mockLogger) Info(format string, args ...interface{}) { + m.messages = append(m.messages, fmt.Sprintf("[INFO] "+format, args...)) +} + +func (m *mockLogger) Error(format string, args ...interface{}) { + m.messages = append(m.messages, fmt.Sprintf("[ERROR] "+format, args...)) +} + +func (m *mockLogger) Debug(format string, args ...interface{}) { + m.messages = append(m.messages, fmt.Sprintf("[DEBUG] "+format, args...)) +} + +func (m *mockLogger) Warn(format string, args ...interface{}) { + m.messages = append(m.messages, fmt.Sprintf("[WARN] "+format, args...)) +} + +func (m *mockLogger) Fatal(format string, args ...interface{}) { + m.messages = append(m.messages, fmt.Sprintf("[FATAL] "+format, args...)) + panic(fmt.Sprintf(format, args...)) +} + +func (m *mockLogger) Title(title string, args ...any) { + m.messages = append(m.messages, fmt.Sprintf("[TITLE] "+title, args...)) +} + +func TestNewOCIArtifactBuilder(t *testing.T) { + logger := &mockLogger{} + builder := NewOCIArtifactBuilder(logger) + + if builder == nil { + t.Fatal("Expected builder to be created") + } + + if builder.logger == nil { + t.Error("Expected logger to be set") + } +} + +func TestCreateConfigBlob(t *testing.T) { + logger := &mockLogger{} + builder := NewOCIArtifactBuilder(logger) + + tests := []struct { + name string + avsName string + tag string + want map[string]interface{} + }{ + { + name: "basic config", + avsName: "test-avs", + tag: "v1.0.0", + want: map[string]interface{}{ + "formatVersion": "1.0", + "eigenRuntimeAPIVersion": "v1", + "kind": "Hourglass", + "avsName": "test-avs", + "validationSchema": "https://eigenruntime.io/schemas/hourglass/v1/manifest.json", + }, + }, + { + name: "special characters in name", + avsName: "my-avs_123", + tag: "opset-0-v2", + want: map[string]interface{}{ + "formatVersion": "1.0", + "eigenRuntimeAPIVersion": "v1", + "kind": "Hourglass", + "avsName": "my-avs_123", + "validationSchema": "https://eigenruntime.io/schemas/hourglass/v1/manifest.json", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configBytes := builder.createConfigBlob(tt.avsName, tt.tag) + + // Verify it's valid JSON + var config map[string]interface{} + if err := json.Unmarshal(configBytes, &config); err != nil { + t.Fatalf("Failed to unmarshal config blob: %v", err) + } + + // Check required fields + for key, expectedValue := range tt.want { + if actualValue, ok := config[key]; !ok { + t.Errorf("Missing key %s in config", key) + } else if actualValue != expectedValue { + t.Errorf("For key %s, got %v, want %v", key, actualValue, expectedValue) + } + } + + // Check metadata exists + metadata, ok := config["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("metadata field missing or not a map") + } + + // Verify metadata fields + if _, ok := metadata["createdAt"]; !ok { + t.Error("metadata.createdAt missing") + } + if metadata["releaseVersion"] != tt.tag { + t.Errorf("metadata.releaseVersion = %v, want %v", metadata["releaseVersion"], tt.tag) + } + if _, ok := metadata["devkitVersion"]; !ok { + t.Error("metadata.devkitVersion missing") + } + }) + } +} + +func TestAddToStore(t *testing.T) { + logger := &mockLogger{} + builder := NewOCIArtifactBuilder(logger) + ctx := context.Background() + + tests := []struct { + name string + mediaType string + content []byte + wantErr bool + }{ + { + name: "add JSON content", + mediaType: "application/json", + content: []byte(`{"test": "data"}`), + wantErr: false, + }, + { + name: "add YAML content", + mediaType: "text/yaml", + content: []byte("key: value\narray:\n - item1\n - item2"), + wantErr: false, + }, + { + name: "add empty content", + mediaType: "application/octet-stream", + content: []byte{}, + wantErr: false, + }, + { + name: "add large content", + mediaType: "application/octet-stream", + content: bytes.Repeat([]byte("a"), 10000), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + store := memory.New() + + desc, err := builder.addToStore(ctx, store, tt.mediaType, tt.content) + if (err != nil) != tt.wantErr { + t.Fatalf("addToStore() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr { + // Verify descriptor + if desc.MediaType != tt.mediaType { + t.Errorf("MediaType = %v, want %v", desc.MediaType, tt.mediaType) + } + if desc.Size != int64(len(tt.content)) { + t.Errorf("Size = %v, want %v", desc.Size, len(tt.content)) + } + if !strings.HasPrefix(string(desc.Digest), "sha256:") { + t.Errorf("Digest should start with sha256:, got %v", desc.Digest) + } + + // Verify content can be fetched from store + rc, err := store.Fetch(ctx, desc) + if err != nil { + t.Fatalf("Failed to fetch content from store: %v", err) + } + defer rc.Close() + + buf := new(bytes.Buffer) + if _, err := buf.ReadFrom(rc); err != nil { + t.Fatalf("Failed to read content: %v", err) + } + + if !bytes.Equal(buf.Bytes(), tt.content) { + t.Error("Fetched content doesn't match original") + } + } + }) + } +} + +func TestComputeRuntimeSpecDigest(t *testing.T) { + tests := []struct { + name string + content []byte + want string + }{ + { + name: "simple text", + content: []byte("hello world"), + want: "sha256:b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9", + }, + { + name: "empty content", + content: []byte{}, + want: "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + { + name: "yaml content", + content: []byte("apiVersion: v1\nkind: Test"), + want: "sha256:10573128831c13c517c4f8ee28a02440058c7f8eaaa163c24ad65dc7e0852b88", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ComputeRuntimeSpecDigest(tt.content) + if got != tt.want { + t.Errorf("ComputeRuntimeSpecDigest() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCreateEigenRuntimeArtifact_ManifestStructure(t *testing.T) { + logger := &mockLogger{} + builder := NewOCIArtifactBuilder(logger) + ctx := context.Background() + + // Create a test runtime spec + runtimeSpec := []byte(`apiVersion: eigenruntime.io/v1 +kind: Hourglass +name: test-avs +version: 1 +spec: + aggregator: + registry: test-registry + digest: sha256:abc123 +`) + + // Create in-memory store and simulate the manifest creation + store := memory.New() + + // Add config + configContent := builder.createConfigBlob("test-avs", "v1.0.0") + configDesc, err := builder.addToStore(ctx, store, "application/vnd.eigenruntime.manifest.config.v1+json", configContent) + if err != nil { + t.Fatalf("Failed to add config: %v", err) + } + + // Add runtime spec + specDesc, err := builder.addToStore(ctx, store, "text/yaml", runtimeSpec) + if err != nil { + t.Fatalf("Failed to add spec: %v", err) + } + + // Create manifest + manifest := ocispec.Manifest{ + MediaType: ocispec.MediaTypeImageManifest, + ArtifactType: "application/vnd.eigenruntime.manifest.v1", + Config: configDesc, + Layers: []ocispec.Descriptor{specDesc}, + Annotations: map[string]string{ + "org.opencontainers.image.source": "https://github.com/Layr-Labs/devkit-cli", + "org.opencontainers.image.description": "EigenRuntime specification for AVS test-avs", + "org.opencontainers.image.created": time.Now().UTC().Format(time.RFC3339), + "io.eigenruntime.spec.version": "v1", + }, + } + + // Create manifest map as in the actual implementation + manifestMap := map[string]interface{}{ + "schemaVersion": 2, + "mediaType": manifest.MediaType, + "artifactType": manifest.ArtifactType, + "config": map[string]interface{}{ + "mediaType": manifest.Config.MediaType, + "digest": manifest.Config.Digest.String(), + "size": manifest.Config.Size, + }, + "layers": func() []map[string]interface{} { + layers := make([]map[string]interface{}, len(manifest.Layers)) + for i, layer := range manifest.Layers { + layers[i] = map[string]interface{}{ + "mediaType": layer.MediaType, + "digest": layer.Digest.String(), + "size": layer.Size, + } + } + return layers + }(), + "annotations": manifest.Annotations, + } + + // Marshal and verify + manifestBytes, err := json.Marshal(manifestMap) + if err != nil { + t.Fatalf("Failed to marshal manifest: %v", err) + } + + // Unmarshal to verify structure + var parsedManifest map[string]interface{} + if err := json.Unmarshal(manifestBytes, &parsedManifest); err != nil { + t.Fatalf("Failed to unmarshal manifest: %v", err) + } + + // Verify required fields + if v, ok := parsedManifest["schemaVersion"].(float64); !ok || v != 2 { + t.Error("schemaVersion should be 2") + } + + if v, ok := parsedManifest["mediaType"].(string); !ok || v != "application/vnd.oci.image.manifest.v1+json" { + t.Error("mediaType incorrect") + } + + if v, ok := parsedManifest["artifactType"].(string); !ok || v != "application/vnd.eigenruntime.manifest.v1" { + t.Error("artifactType incorrect or missing") + } + + // Verify config + config, ok := parsedManifest["config"].(map[string]interface{}) + if !ok { + t.Fatal("config field missing or not a map") + } + + if v, ok := config["mediaType"].(string); !ok || v != "application/vnd.eigenruntime.manifest.config.v1+json" { + t.Error("config.mediaType incorrect") + } + + // Verify layers + layers, ok := parsedManifest["layers"].([]interface{}) + if !ok || len(layers) != 1 { + t.Fatal("layers field missing or incorrect length") + } + + layer0, ok := layers[0].(map[string]interface{}) + if !ok { + t.Fatal("layer[0] not a map") + } + + if v, ok := layer0["mediaType"].(string); !ok || v != "text/yaml" { + t.Error("layer[0].mediaType incorrect") + } + + // Verify annotations exist + if _, ok := parsedManifest["annotations"].(map[string]interface{}); !ok { + t.Error("annotations field missing") + } +} + +func TestCreateEigenRuntimeArtifact_Errors(t *testing.T) { + logger := &mockLogger{} + builder := NewOCIArtifactBuilder(logger) + + tests := []struct { + name string + runtimeSpec []byte + registry string + avsName string + tag string + wantErr string + }{ + { + name: "invalid registry format", + runtimeSpec: []byte("test"), + registry: "invalid registry!@#", + avsName: "test", + tag: "v1", + wantErr: "failed to create repository", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := builder.CreateEigenRuntimeArtifact( + tt.runtimeSpec, + tt.registry, + tt.avsName, + tt.tag, + ) + + if err == nil { + t.Fatal("Expected error but got none") + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("Error = %v, want error containing %v", err, tt.wantErr) + } + }) + } +} diff --git a/pkg/common/contract_caller.go b/pkg/common/contract_caller.go index 36817720..0e4652d9 100644 --- a/pkg/common/contract_caller.go +++ b/pkg/common/contract_caller.go @@ -707,7 +707,7 @@ func (cc *ContractCaller) GetRegistry() *contracts.ContractRegistry { return cc.registry } -func (cc *ContractCaller) PublishRelease(ctx context.Context, avsAddress common.Address, artifacts []releasemanager.IReleaseManagerTypesArtifact, operatorSetId uint32, upgradeByTime int64) error { +func (cc *ContractCaller) PublishRelease(ctx context.Context, avsAddress common.Address, artifacts []releasemanager.IReleaseManagerTypesArtifact, operatorSetId uint32, upgradeByTime uint32) error { opts, err := cc.buildTxOpts() if err != nil { return fmt.Errorf("failed to build transaction options: %w", err) @@ -719,21 +719,57 @@ func (cc *ContractCaller) PublishRelease(ctx context.Context, avsAddress common. operatorSet := releasemanager.OperatorSet{Avs: avsAddress, Id: operatorSetId} release := releasemanager.IReleaseManagerTypesRelease{ Artifacts: artifacts, - UpgradeByTime: uint32(upgradeByTime), + UpgradeByTime: upgradeByTime, } return cc.SendAndWaitForTransaction(ctx, "PublishRelease", func() (*types.Transaction, error) { tx, err := releaseManager.PublishRelease(opts, operatorSet, release) if err == nil && tx != nil { - cc.logger.Debug( - "Transaction hash for PublishRelease: %s\n"+ + cc.logger.Info("Transaction hash for PublishRelease: %s", tx.Hash().Hex()) + } + return tx, err + }) +} + +func (cc *ContractCaller) GetReleaseMetadataUri(avsAddress common.Address, operatorSetId uint32) (string, error) { + releaseManager, err := cc.registry.GetReleaseManager(cc.releaseManagerAddr) + if err != nil { + return "", fmt.Errorf("failed to get ReleaseManager: %w", err) + } + operatorSet := releasemanager.OperatorSet{Avs: avsAddress, Id: operatorSetId} + uri, err := releaseManager.GetMetadataURI(&bind.CallOpts{}, operatorSet) + if err == nil { + cc.logger.Info("release metadata uri found %s, for %s", uri, operatorSet) + } + return uri, err +} + +func (cc *ContractCaller) SetReleaseMetadata( + ctx context.Context, + metadataUri string, + avsAddress common.Address, + operatorSetId uint32, +) error { + opts, err := cc.buildTxOpts() + if err != nil { + return fmt.Errorf("failed to build transaction options: %w", err) + } + releaseManager, err := cc.registry.GetReleaseManager(cc.releaseManagerAddr) + if err != nil { + return fmt.Errorf("failed to set ReleaseManager metadata uri: %w", err) + } + operatorSet := releasemanager.OperatorSet{Avs: avsAddress, Id: operatorSetId} + return cc.SendAndWaitForTransaction(ctx, "PublishMetadataURI", func() (*types.Transaction, error) { + tx, err := releaseManager.PublishMetadataURI(opts, operatorSet, metadataUri) + if err == nil && tx != nil { + cc.logger.Info( + "Transaction hash for PublishMetadataUri: %s\n"+ "operatorSet: %s\n"+ - "release: %s", + "uri: %s", tx.Hash().Hex(), operatorSet, - release, + metadataUri, ) } return tx, err }) - } diff --git a/pkg/template/config_test.go b/pkg/template/config_test.go index 69104343..541065d9 100644 --- a/pkg/template/config_test.go +++ b/pkg/template/config_test.go @@ -17,7 +17,7 @@ func TestLoadConfig(t *testing.T) { } expectedBaseURL := "https://github.com/Layr-Labs/hourglass-avs-template" - expectedVersion := "v0.0.14" + expectedVersion := "v0.0.16" if mainBaseURL != expectedBaseURL { t.Errorf("Unexpected main template base URL: got %s, want %s", mainBaseURL, expectedBaseURL)