diff --git a/cmd/run/main_test.go b/cmd/run/main_test.go index ed948fd..b54066e 100644 --- a/cmd/run/main_test.go +++ b/cmd/run/main_test.go @@ -106,4 +106,65 @@ var _ = Describe("Main", func() { Entry("When the FABRIC_K8S_BUILDER_START_TIMEOUT is missing a duration unit", "3", `run \[\d+\]: The FABRIC_K8S_BUILDER_START_TIMEOUT environment variable must be a valid Go duration string, e\.g\. 3m40s: time: missing unit in duration "3"`), Entry("When the FABRIC_K8S_BUILDER_START_TIMEOUT is not a valid duration string", "three minutes", `run \[\d+\]: The FABRIC_K8S_BUILDER_START_TIMEOUT environment variable must be a valid Go duration string, e\.g\. 3m40s: time: invalid duration "three minutes"`), ) + DescribeTable("Running the run command produces the correct error for invalid FABRIC_K8S_BUILDER_NAME_SERVERS environment variable values", + func(nameServersValue, expectedErrorMessage string) { + args := []string{"BUILD_OUTPUT_DIR", "RUN_METADATA_DIR"} + command := exec.Command(runCmdPath, args...) + command.Env = append(os.Environ(), + "CORE_PEER_ID=core-peer-id-abcdefghijklmnopqrstuvwxyz-0123456789", + "FABRIC_K8S_BUILDER_NAME_SERVERS="+nameServersValue, + ) + session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) + Expect(err).NotTo(HaveOccurred()) + + Eventually(session).Should(gexec.Exit(1)) + Eventually( + session.Err, + ).Should(gbytes.Say(expectedErrorMessage)) + }, + Entry("When the FABRIC_K8S_BUILDER_NAME_SERVERS is not a valid IP address", "invalid-ip", `run \[\d+\]: The FABRIC_K8S_BUILDER_NAME_SERVERS environment variable must be a valid IP address`), + Entry("When the FABRIC_K8S_BUILDER_NAME_SERVERS is an incomplete IP", "192.168.1", `run \[\d+\]: The FABRIC_K8S_BUILDER_NAME_SERVERS environment variable must be a valid IP address`), + Entry("When the FABRIC_K8S_BUILDER_NAME_SERVERS contains invalid characters", "10.96.0.10.extra", `run \[\d+\]: The FABRIC_K8S_BUILDER_NAME_SERVERS environment variable must be a valid IP address`), + ) + DescribeTable("Running the run command produces the correct error for invalid FABRIC_K8S_BUILDER_CUSTOM_ANNOTATIONS environment variable values", + func(customAnnotationsValue, expectedErrorMessage string) { + args := []string{"BUILD_OUTPUT_DIR", "RUN_METADATA_DIR"} + command := exec.Command(runCmdPath, args...) + command.Env = append(os.Environ(), + "CORE_PEER_ID=core-peer-id-abcdefghijklmnopqrstuvwxyz-0123456789", + "FABRIC_K8S_BUILDER_CUSTOM_ANNOTATIONS="+customAnnotationsValue, + ) + session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) + Expect(err).NotTo(HaveOccurred()) + + Eventually(session).Should(gexec.Exit(1)) + Eventually( + session.Err, + ).Should(gbytes.Say(expectedErrorMessage)) + }, + Entry("When the FABRIC_K8S_BUILDER_CUSTOM_ANNOTATIONS contains invalid annotation key", "invalid*key=value", `run \[\d+\]: The FABRIC_K8S_BUILDER_CUSTOM_ANNOTATIONS environment variable contains an invalid annotation key 'invalid\*key': must be a valid Kubernetes annotation key`), + Entry("When the FABRIC_K8S_BUILDER_CUSTOM_ANNOTATIONS contains key starting with dash", "-key=value", `run \[\d+\]: The FABRIC_K8S_BUILDER_CUSTOM_ANNOTATIONS environment variable contains an invalid annotation key '-key': must be a valid Kubernetes annotation key`), + ) + + DescribeTable("Running the run command produces the correct error for invalid FABRIC_K8S_BUILDER_HOST_ALIASES environment variable values", + func(hostAliasesValue, expectedErrorMessage string) { + args := []string{"BUILD_OUTPUT_DIR", "RUN_METADATA_DIR"} + command := exec.Command(runCmdPath, args...) + command.Env = append(os.Environ(), + "CORE_PEER_ID=core-peer-id-abcdefghijklmnopqrstuvwxyz-0123456789", + "FABRIC_K8S_BUILDER_HOST_ALIASES="+hostAliasesValue, + ) + session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) + Expect(err).NotTo(HaveOccurred()) + + Eventually(session).Should(gexec.Exit(1)) + Eventually( + session.Err, + ).Should(gbytes.Say(expectedErrorMessage)) + }, + Entry("When the FABRIC_K8S_BUILDER_HOST_ALIASES is not valid JSON", `[{"ip":"1.2.3.4"`, `run \[\d+\]: The FABRIC_K8S_BUILDER_HOST_ALIASES environment variable must be a valid JSON array`), + Entry("When the FABRIC_K8S_BUILDER_HOST_ALIASES contains invalid IP", `[{"ip":"invalid","hostnames":["foo.com"]}]`, `run \[\d+\]: The FABRIC_K8S_BUILDER_HOST_ALIASES environment variable contains an invalid IP address 'invalid' at index 0`), + Entry("When the FABRIC_K8S_BUILDER_HOST_ALIASES contains empty IP", `[{"ip":"","hostnames":["foo.com"]}]`, `run \[\d+\]: The FABRIC_K8S_BUILDER_HOST_ALIASES environment variable contains a host alias at index 0 with an empty IP address`), + Entry("When the FABRIC_K8S_BUILDER_HOST_ALIASES contains no hostnames", `[{"ip":"1.2.3.4","hostnames":[]}]`, `run \[\d+\]: The FABRIC_K8S_BUILDER_HOST_ALIASES environment variable contains a host alias at index 0 with no hostnames`), + ) }) diff --git a/docs/configuring/configmap-env-vars.md b/docs/configuring/configmap-env-vars.md new file mode 100644 index 0000000..494a5b6 --- /dev/null +++ b/docs/configuring/configmap-env-vars.md @@ -0,0 +1,213 @@ +# ConfigMap Environment Variables + +The Fabric K8s Builder supports injecting environment variables into chaincode pods from Kubernetes ConfigMaps. This allows you to configure chaincode behavior without rebuilding container images. + +## Overview + +Environment variables are automatically loaded from a ConfigMap if it exists with the same name as the chaincode label. No additional configuration is required on the peer. + +## How It Works + +When deploying chaincode: +1. The builder extracts the chaincode label from the package ID +2. It checks if a ConfigMap exists with that exact name +3. If found, all key-value pairs from the ConfigMap are mounted as environment variables +4. If not found, the chaincode pod starts normally without the additional environment variables + +## Configuration + +### 1. Package Your Chaincode + +Package your chaincode with a label: + +```bash +peer lifecycle chaincode package mycc.tar.gz \ + --path ./chaincode \ + --lang k8s \ + --label mycc_v1 +``` + +The label `mycc_v1` will be used as the ConfigMap name. + +### 2. Create the ConfigMap + +Create a ConfigMap with the **exact same name** as your chaincode label: + +```bash +kubectl create configmap mycc_v1 \ + --from-literal=LOG_LEVEL=debug \ + --from-literal=DATABASE_URL=postgres://db:5432/mydb \ + --from-literal=API_KEY=secret123 \ + -n hyperledger +``` + +Or using a YAML file: + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: mycc_v1 + namespace: hyperledger +data: + LOG_LEVEL: "debug" + DATABASE_URL: "postgres://db:5432/mydb" + API_KEY: "secret123" + FEATURE_FLAG_X: "enabled" +``` + +Apply it: + +```bash +kubectl apply -f mycc-configmap.yaml +``` + +### 3. Deploy Your Chaincode + +Install and deploy your chaincode normally: + +```bash +peer lifecycle chaincode install mycc.tar.gz +peer lifecycle chaincode approveformyorg --channelID mychannel --name mycc --version 1.0 --package-id mycc_v1:hash... +peer lifecycle chaincode commit --channelID mychannel --name mycc --version 1.0 +``` + +The chaincode pod will automatically have access to all environment variables from the ConfigMap. + +## Naming Convention + +**Important:** The ConfigMap name must **exactly match** the chaincode label. + +### Examples + +| Chaincode Label | ConfigMap Name | Status | +|----------------|----------------|--------| +| `mycc_v1` | `mycc_v1` | ✅ Valid | +| `asset-transfer` | `asset-transfer` | ✅ Valid | +| `basic_1.0` | `basic_1.0` | ✅ Valid | +| `mycc_v1` | `mycc-v1-config` | ❌ Invalid - doesn't match | +| `mycc_v1` | `mycc` | ❌ Invalid - doesn't match | + +## Environment Variable Priority + +Environment variables are loaded in this order (later values override earlier ones): + +1. **Fabric Core Variables** - Built-in variables like `CORE_CHAINCODE_ID_NAME`, `CORE_PEER_ADDRESS`, etc. +2. **ConfigMap Variables** - Variables from the ConfigMap matching the chaincode label + +## Complete Example + +### Step 1: Create ConfigMap First + +```bash +kubectl create configmap asset_transfer_v1 \ + --from-literal=LOG_LEVEL=info \ + --from-literal=MAX_CONNECTIONS=100 \ + --from-literal=CACHE_TTL=3600 \ + -n hyperledger +``` + +### Step 2: Package Chaincode + +```bash +peer lifecycle chaincode package asset-transfer.tar.gz \ + --path ./asset-transfer-chaincode \ + --lang k8s \ + --label asset_transfer_v1 +``` + +### Step 3: Install and Deploy + +```bash +# Install +peer lifecycle chaincode install asset-transfer.tar.gz + +# Get package ID +peer lifecycle chaincode queryinstalled + +# Approve (replace PACKAGE_ID with actual value) +peer lifecycle chaincode approveformyorg \ + --channelID mychannel \ + --name asset-transfer \ + --version 1.0 \ + --package-id asset_transfer_v1:abc123... + +# Commit +peer lifecycle chaincode commit \ + --channelID mychannel \ + --name asset-transfer \ + --version 1.0 +``` + +The chaincode pod will now have `LOG_LEVEL`, `MAX_CONNECTIONS`, and `CACHE_TTL` environment variables available. + +## Updating Configuration + +To update environment variables without redeploying chaincode: + +### Option 1: Edit ConfigMap Directly + +```bash +kubectl edit configmap mycc_v1 -n hyperledger +``` + +### Option 2: Apply Updated YAML + +```bash +kubectl apply -f mycc-configmap.yaml +``` + +### Restart Chaincode Pod + +After updating the ConfigMap, restart the chaincode pod to pick up changes: + +```bash +# Find the pod +kubectl get pods -l fabric-builder-k8s-cclabel=mycc_v1 -n hyperledger + +# Delete it (peer will recreate it automatically) +kubectl delete pod -n hyperledger +``` + +Or delete by label: + +```bash +kubectl delete pod -l fabric-builder-k8s-cclabel=mycc_v1 -n hyperledger +``` + +## Multiple Versions + +You can have different configurations for different versions of the same chaincode: + +```bash +# Version 1 +kubectl create configmap mycc_v1 \ + --from-literal=FEATURE_X=disabled \ + -n hyperledger + +# Version 2 +kubectl create configmap mycc_v2 \ + --from-literal=FEATURE_X=enabled \ + -n hyperledger +``` + +Each version will use its own ConfigMap based on the label. + + +### Wrong ConfigMap Name + +Ensure the ConfigMap name exactly matches the chaincode label. Check your package label: + +```bash +peer lifecycle chaincode queryinstalled +``` + +The label is shown in the package ID: `label:hash` + +## Best Practices + +1. **Create ConfigMap Before Deployment**: Create the ConfigMap before installing the chaincode to ensure it's available immediately +2. **Use Descriptive Labels**: Use clear, version-specific labels like `mycc_v1`, `mycc_v2` instead of generic names +3. **Document Variables**: Add comments in your ConfigMap YAML to document what each variable does +4. **Version Control**: Store ConfigMap YAML files in version control alongside your chaincode +5. **Environment-Specific ConfigMaps**: Use different ConfigMaps for dev, staging, and production environments \ No newline at end of file diff --git a/docs/configuring/overview.md b/docs/configuring/overview.md index 1c05e56..5184cd6 100644 --- a/docs/configuring/overview.md +++ b/docs/configuring/overview.md @@ -18,6 +18,9 @@ externalBuilders: - FABRIC_K8S_BUILDER_OBJECT_NAME_PREFIX - FABRIC_K8S_BUILDER_SERVICE_ACCOUNT - FABRIC_K8S_BUILDER_START_TIMEOUT + - FABRIC_K8S_BUILDER_NAME_SERVERS + - FABRIC_K8S_BUILDER_CUSTOM_ANNOTATIONS + - FABRIC_K8S_BUILDER_HOST_ALIASES - KUBERNETES_SERVICE_HOST - KUBERNETES_SERVICE_PORT ``` @@ -41,14 +44,68 @@ For more information, see [Configuring external builders and launchers](https:// The k8s builder is configured using the following environment variables. -| Name | Default | Description | -| ------------------------------------- | -------------------------------- | ---------------------------------------------------- | -| CORE_PEER_ID | | The Fabric peer ID (required) | -| FABRIC_K8S_BUILDER_NAMESPACE | The peer namespace or `default` | The Kubernetes namespace to run chaincode with | -| FABRIC_K8S_BUILDER_NODE_ROLE | | Use dedicated Kubernetes nodes to run chaincode | -| FABRIC_K8S_BUILDER_OBJECT_NAME_PREFIX | `hlfcc` | Eye-catcher prefix for Kubernetes object names | -| FABRIC_K8S_BUILDER_SERVICE_ACCOUNT | `default` | The Kubernetes service account to run chaincode with | -| FABRIC_K8S_BUILDER_START_TIMEOUT | `3m` | The timeout when waiting for chaincode pods to start | -| FABRIC_K8S_BUILDER_DEBUG | `false` | Set to `true` to enable k8s builder debug messages | +| Name | Default | Description | +| ---------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------- | +| CORE_PEER_ID | | The Fabric peer ID (required) | +| FABRIC_K8S_BUILDER_NAMESPACE | The peer namespace or `default` | The Kubernetes namespace to run chaincode with | +| FABRIC_K8S_BUILDER_NODE_ROLE | | Use dedicated Kubernetes nodes to run chaincode | +| FABRIC_K8S_BUILDER_OBJECT_NAME_PREFIX | `hlfcc` | Eye-catcher prefix for Kubernetes object names | +| FABRIC_K8S_BUILDER_SERVICE_ACCOUNT | `default` | The Kubernetes service account to run chaincode with | +| FABRIC_K8S_BUILDER_START_TIMEOUT | `3m` | The timeout when waiting for chaincode pods to start | +| FABRIC_K8S_BUILDER_NAME_SERVERS | | Custom DNS nameserver IP for chaincode pods (optional, enables custom DNS) | +| FABRIC_K8S_BUILDER_CUSTOM_ANNOTATIONS | | Custom annotations for chaincode pods (optional, comma-separated key=value pairs)| +| FABRIC_K8S_BUILDER_HOST_ALIASES | | Host aliases for chaincode pods (optional, JSON array format) | +| FABRIC_K8S_BUILDER_DEBUG | `false` | Set to `true` to enable k8s builder debug messages | The k8s builder can be run in cluster using the `KUBERNETES_SERVICE_HOST` and `KUBERNETES_SERVICE_PORT` environment variables, or it can connect using a `KUBECONFIG_PATH` environment variable. + + +## Host Aliases Configuration + +The `FABRIC_K8S_BUILDER_HOST_ALIASES` environment variable allows you to add custom host-to-IP mappings in chaincode pods. This is useful when chaincode needs to resolve custom hostnames that are not available in DNS. + +### Format + +The value must be a valid JSON array of host alias objects. Each object contains: +- `ip`: The IP address (string) +- `hostnames`: Array of hostnames that should resolve to this IP (array of strings) + +### Example + +```bash +export FABRIC_K8S_BUILDER_HOST_ALIASES='[{"ip":"1.2.3.4","hostnames":["foo.com","bar.com"]},{"ip":"5.6.7.8","hostnames":["example.org"]}]' +``` + +This configuration will add the following entries to the chaincode pod's `/etc/hosts` file: +``` +1.2.3.4 foo.com bar.com +5.6.7.8 example.org +``` + +### Use Cases + +- **Private Services**: Resolve internal service names to private IP addresses +- **Testing**: Override DNS resolution for testing purposes +- **Legacy Systems**: Connect to systems with non-standard hostname requirements +- **Service Mesh**: Custom routing for service mesh configurations + +### Validation + +The builder will validate the JSON format when creating chaincode pods. Invalid JSON will cause the chaincode deployment to fail with an error message indicating the parsing issue. + +### Example Configurations + +**Single host alias:** +```bash +export FABRIC_K8S_BUILDER_HOST_ALIASES='[{"ip":"192.168.1.100","hostnames":["database.local"]}]' +``` + +**Multiple hosts to same IP:** +```bash +export FABRIC_K8S_BUILDER_HOST_ALIASES='[{"ip":"10.0.0.50","hostnames":["api.internal","api.local","api"]}]' +``` + +**Multiple IP mappings:** +```bash +export FABRIC_K8S_BUILDER_HOST_ALIASES='[{"ip":"10.0.1.10","hostnames":["db1.local"]},{"ip":"10.0.1.11","hostnames":["db2.local"]},{"ip":"10.0.1.12","hostnames":["cache.local"]}]' +``` diff --git a/internal/builder/run.go b/internal/builder/run.go index 0041c16..0dd3ec8 100644 --- a/internal/builder/run.go +++ b/internal/builder/run.go @@ -9,6 +9,7 @@ import ( "github.com/hyperledger-labs/fabric-builder-k8s/internal/log" "github.com/hyperledger-labs/fabric-builder-k8s/internal/util" + apiv1 "k8s.io/api/core/v1" ) type Run struct { @@ -21,6 +22,9 @@ type Run struct { KubeServiceAccount string KubeNamePrefix string ChaincodeStartTimeout time.Duration + NameServers string + CustomAnnotations map[string]string + KubeHostAliases []apiv1.HostAlias } func (r *Run) Run(ctx context.Context) error { @@ -68,17 +72,22 @@ func (r *Run) Run(ctx context.Context) error { } jobsClient := clientset.BatchV1().Jobs(r.KubeNamespace) + configMapsClient := clientset.CoreV1().ConfigMaps(r.KubeNamespace) job, err := util.CreateChaincodeJob( ctx, logger, jobsClient, + configMapsClient, kubeObjectName, r.KubeNamespace, r.KubeServiceAccount, r.KubeNodeRole, r.PeerID, + r.NameServers, + r.CustomAnnotations, chaincodeData, + r.KubeHostAliases, imageData, ) if err != nil { diff --git a/internal/cmd/run.go b/internal/cmd/run.go index aee6194..9b0f782 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -4,12 +4,14 @@ package cmd import ( "context" + "encoding/json" "os" "time" "github.com/hyperledger-labs/fabric-builder-k8s/internal/builder" "github.com/hyperledger-labs/fabric-builder-k8s/internal/log" "github.com/hyperledger-labs/fabric-builder-k8s/internal/util" + apiv1 "k8s.io/api/core/v1" apivalidation "k8s.io/apimachinery/pkg/api/validation" "k8s.io/apimachinery/pkg/util/validation" ) @@ -98,6 +100,45 @@ func getKubeNamePrefix(logger *log.CmdLogger) (kubeNamePrefix string, ok bool) { return kubeNamePrefix, true } +//nolint:nonamedreturns // using the ok bool convention to indicate errors +func getKubeHostAliases(logger *log.CmdLogger) (hostAliases []apiv1.HostAlias, ok bool) { + raw := util.GetOptionalEnv(util.ChaincodeHostAliasesVariable, "") + logger.Debugf("%s=%s", util.ChaincodeHostAliasesVariable, raw) + + if raw == "" { + return nil, true + } + + if err := json.Unmarshal([]byte(raw), &hostAliases); err != nil { + logger.Printf( + `The %s environment variable must be a valid JSON array, e.g. [{"ip":"1.2.3.4","hostnames":["foo.com"]}]: %v`, + util.ChaincodeHostAliasesVariable, err, + ) + + return nil, false + } + + // Validate IP addresses in host aliases + for i, hostAlias := range hostAliases { + if hostAlias.IP == "" { + logger.Printf("The %s environment variable contains a host alias at index %d with an empty IP address", util.ChaincodeHostAliasesVariable, i) + return nil, false + } + + if !util.IsValidIPAddress(hostAlias.IP) { + logger.Printf("The %s environment variable contains an invalid IP address '%s' at index %d", util.ChaincodeHostAliasesVariable, hostAlias.IP, i) + return nil, false + } + + if len(hostAlias.Hostnames) == 0 { + logger.Printf("The %s environment variable contains a host alias at index %d with no hostnames", util.ChaincodeHostAliasesVariable, i) + return nil, false + } + } + + return hostAliases, true +} + //nolint:nonamedreturns // using the ok bool convention to indicate errors func getChaincodeStartTimeout(logger *log.CmdLogger) (chaincodeStartTimeoutDuration time.Duration, ok bool) { chaincodeStartTimeout := util.GetOptionalEnv(util.ChaincodeStartTimeoutVariable, util.DefaultStartTimeout) @@ -113,6 +154,48 @@ func getChaincodeStartTimeout(logger *log.CmdLogger) (chaincodeStartTimeoutDurat return chaincodeStartTimeoutDuration, true } +//nolint:nonamedreturns // using the ok bool convention to indicate errors +func getNameServers(logger *log.CmdLogger) (nameServers string, ok bool) { + nameServers = util.GetOptionalEnv(util.NameServersVariable, "") + logger.Debugf("%s=%s", util.NameServersVariable, nameServers) + + if nameServers == "" { + return nameServers, true + } + + // Validate IP address format + if !util.IsValidIPAddress(nameServers) { + logger.Printf("The %s environment variable must be a valid IP address", util.NameServersVariable) + return "", false + } + + return nameServers, true +} + +//nolint:nonamedreturns // using the ok bool convention to indicate errors +func getCustomAnnotations(logger *log.CmdLogger) (annotations map[string]string, ok bool) { + annotationsStr := util.GetOptionalEnv(util.CustomAnnotationsVariable, "") + logger.Debugf("%s=%s", util.CustomAnnotationsVariable, annotationsStr) + + if annotationsStr == "" { + return make(map[string]string), true + } + + annotations = util.ParseAnnotations(annotationsStr) + + // Validate annotation keys follow Kubernetes naming conventions + for key := range annotations { + if !util.IsValidAnnotationKey(key) { + logger.Printf("The %s environment variable contains an invalid annotation key '%s': must be a valid Kubernetes annotation key", util.CustomAnnotationsVariable, key) + return nil, false + } + } + + logger.Debugf("Parsed custom annotations: %v", annotations) + + return annotations, true +} + func Run() { const ( expectedArgsLength = 3 @@ -164,6 +247,21 @@ func Run() { os.Exit(1) } + nameServers, ok := getNameServers(logger) + if !ok { + os.Exit(1) + } + + customAnnotations, ok := getCustomAnnotations(logger) + if !ok { + os.Exit(1) + } + + kubeHostAliases, ok := getKubeHostAliases(logger) + if !ok { + os.Exit(1) + } + run := &builder.Run{ BuildOutputDirectory: buildOutputDirectory, RunMetadataDirectory: runMetadataDirectory, @@ -174,6 +272,9 @@ func Run() { KubeServiceAccount: kubeServiceAccount, KubeNamePrefix: kubeNamePrefix, ChaincodeStartTimeout: chaincodeStartTimeout, + NameServers: nameServers, + CustomAnnotations: customAnnotations, + KubeHostAliases: kubeHostAliases, } if err := run.Run(ctx); err != nil { diff --git a/internal/util/env.go b/internal/util/env.go index 9d75c7a..06c3974 100644 --- a/internal/util/env.go +++ b/internal/util/env.go @@ -4,7 +4,10 @@ package util import ( "fmt" + "net" "os" + "regexp" + "strings" ) const ( @@ -14,6 +17,9 @@ const ( ObjectNamePrefixVariable = builderVariablePrefix + "OBJECT_NAME_PREFIX" ChaincodeServiceAccountVariable = builderVariablePrefix + "SERVICE_ACCOUNT" ChaincodeStartTimeoutVariable = builderVariablePrefix + "START_TIMEOUT" + NameServersVariable = builderVariablePrefix + "NAME_SERVERS" + CustomAnnotationsVariable = builderVariablePrefix + "CUSTOM_ANNOTATIONS" + ChaincodeHostAliasesVariable = builderVariablePrefix + "HOST_ALIASES" DebugVariable = builderVariablePrefix + "DEBUG" KubeconfigPathVariable = "KUBECONFIG_PATH" PeerIDVariable = "CORE_PEER_ID" @@ -34,3 +40,80 @@ func GetRequiredEnv(key string) (string, error) { return "", fmt.Errorf("environment variable not set: %s", key) } + +// ParseAnnotations parses a comma-separated list of key=value pairs into a map. +// Example input: "sidecar.istio.io/inject=true,app=myapp" +// Returns empty map if input is empty or invalid entries are skipped. +func ParseAnnotations(annotationsStr string) map[string]string { + annotations := make(map[string]string) + + if annotationsStr == "" { + return annotations + } + + pairs := strings.Split(annotationsStr, ",") + for _, pair := range pairs { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + + parts := strings.SplitN(pair, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + if key != "" { + annotations[key] = value + } + } + } + + return annotations +} + +// IsValidIPAddress validates if a string is a valid IPv4 or IPv6 address. +func IsValidIPAddress(ip string) bool { + return net.ParseIP(ip) != nil +} + +// IsValidAnnotationKey validates if a string is a valid Kubernetes annotation key. +// Annotation keys must be in the format: [prefix/]name +// - prefix (optional): DNS subdomain (max 253 chars) +// - name (required): max 63 chars, alphanumeric, '-', '_', '.' +func IsValidAnnotationKey(key string) bool { + if key == "" { + return false + } + + // Split into prefix and name + parts := strings.SplitN(key, "/", 2) + + var prefix, name string + if len(parts) == 2 { + prefix = parts[0] + name = parts[1] + } else { + name = parts[0] + } + + // Validate prefix if present (DNS subdomain format) + if prefix != "" { + if len(prefix) > 253 { + return false + } + // DNS subdomain regex: lowercase alphanumeric, '-', '.' + dnsSubdomainRegex := regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$`) + if !dnsSubdomainRegex.MatchString(prefix) { + return false + } + } + + // Validate name (required) + if name == "" || len(name) > 63 { + return false + } + + // Name must be alphanumeric, '-', '_', '.' and start/end with alphanumeric + nameRegex := regexp.MustCompile(`^[a-zA-Z0-9]([-a-zA-Z0-9_.]*[a-zA-Z0-9])?$`) + return nameRegex.MatchString(name) +} diff --git a/internal/util/k8s.go b/internal/util/k8s.go index 3866dfa..5484321 100644 --- a/internal/util/k8s.go +++ b/internal/util/k8s.go @@ -266,9 +266,89 @@ func getAnnotations(peerID string, chaincodeData *ChaincodeJSON) map[string]stri } } +func buildEnvVars(chaincodeData *ChaincodeJSON) []apiv1.EnvVar { + return []apiv1.EnvVar{ + { + Name: "CORE_CHAINCODE_ID_NAME", + Value: chaincodeData.ChaincodeID, + }, + { + Name: "CORE_PEER_ADDRESS", + Value: chaincodeData.PeerAddress, + }, + { + Name: "CORE_PEER_TLS_ENABLED", + Value: "true", + }, + { + Name: "CORE_PEER_TLS_ROOTCERT_FILE", + Value: TLSClientRootCertFile, + }, + { + Name: "CORE_TLS_CLIENT_KEY_PATH", + Value: TLSClientKeyPath, + }, + { + Name: "CORE_TLS_CLIENT_CERT_PATH", + Value: TLSClientCertPath, + }, + { + Name: "CORE_TLS_CLIENT_KEY_FILE", + Value: TLSClientKeyFile, + }, + { + Name: "CORE_TLS_CLIENT_CERT_FILE", + Value: TLSClientCertFile, + }, + { + Name: "CORE_PEER_LOCALMSPID", + Value: chaincodeData.MspID, + }, + } +} + +func getConfigMapName(chaincodeData *ChaincodeJSON) string { + packageID := NewChaincodePackageID(chaincodeData.ChaincodeID) + // Use chaincode label as ConfigMap name + return packageID.Label +} + +func buildEnvFrom( + ctx context.Context, + logger *log.CmdLogger, + configMapsClient v1.ConfigMapInterface, + chaincodeData *ChaincodeJSON, +) []apiv1.EnvFromSource { + configMapName := getConfigMapName(chaincodeData) + + // Check if ConfigMap exists + _, err := configMapsClient.Get(ctx, configMapName, metav1.GetOptions{}) + if err != nil { + logger.Debugf("ConfigMap %s not found, skipping environment variables from ConfigMap: %v", configMapName, err) + return nil + } + + logger.Debugf("Found ConfigMap %s, will mount as environment variables", configMapName) + + return []apiv1.EnvFromSource{ + { + ConfigMapRef: &apiv1.ConfigMapEnvSource{ + LocalObjectReference: apiv1.LocalObjectReference{ + Name: configMapName, + }, + Optional: ptr.To(true), + }, + }, + } +} + func getChaincodeJobSpec( + ctx context.Context, + logger *log.CmdLogger, + configMapsClient v1.ConfigMapInterface, imageData *ImageJSON, - namespace, serviceAccount, objectName, peerID string, + namespace, serviceAccount, objectName, peerID, nameServers string, + customAnnotations map[string]string, chaincodeData *ChaincodeJSON, ) (*batchv1.Job, error) { chaincodeImage := imageData.Name + "@" + imageData.Digest @@ -282,6 +362,11 @@ func getChaincodeJobSpec( annotations := getAnnotations(peerID, chaincodeData) + // Merge custom annotations if provided + for key, value := range customAnnotations { + annotations[key] = value + } + return &batchv1.Job{ ObjectMeta: metav1.ObjectMeta{ Name: jobName, @@ -308,44 +393,8 @@ func getChaincodeJobSpec( ReadOnly: true, }, }, - Env: []apiv1.EnvVar{ - { - Name: "CORE_CHAINCODE_ID_NAME", - Value: chaincodeData.ChaincodeID, - }, - { - Name: "CORE_PEER_ADDRESS", - Value: chaincodeData.PeerAddress, - }, - { - Name: "CORE_PEER_TLS_ENABLED", - Value: "true", // TODO only if there are certs? - }, - { - Name: "CORE_PEER_TLS_ROOTCERT_FILE", - Value: TLSClientRootCertFile, - }, - { - Name: "CORE_TLS_CLIENT_KEY_PATH", - Value: TLSClientKeyPath, - }, - { - Name: "CORE_TLS_CLIENT_CERT_PATH", - Value: TLSClientCertPath, - }, - { - Name: "CORE_TLS_CLIENT_KEY_FILE", - Value: TLSClientKeyFile, - }, - { - Name: "CORE_TLS_CLIENT_CERT_FILE", - Value: TLSClientCertFile, - }, - { - Name: "CORE_PEER_LOCALMSPID", - Value: chaincodeData.MspID, - }, - }, + Env: buildEnvVars(chaincodeData), + EnvFrom: buildEnvFrom(ctx, logger, configMapsClient, chaincodeData), }, }, RestartPolicy: apiv1.RestartPolicyNever, @@ -429,16 +478,24 @@ func CreateChaincodeJob( ctx context.Context, logger *log.CmdLogger, jobsClient typedBatchv1.JobInterface, - objectName, namespace, serviceAccount, nodeRole, peerID string, + configMapsClient v1.ConfigMapInterface, + objectName, namespace, serviceAccount, nodeRole, peerID, nameServers string, + customAnnotations map[string]string, chaincodeData *ChaincodeJSON, + hostAliases []apiv1.HostAlias, imageData *ImageJSON, ) (*batchv1.Job, error) { jobDefinition, err := getChaincodeJobSpec( + ctx, + logger, + configMapsClient, imageData, namespace, serviceAccount, objectName, peerID, + nameServers, + customAnnotations, chaincodeData, ) if err != nil { @@ -451,7 +508,12 @@ func CreateChaincodeJob( chaincodeData.ChaincodeID, nodeRole, ) - + if nameServers != "" { + jobDefinition.Spec.Template.Spec.DNSPolicy = apiv1.DNSNone + jobDefinition.Spec.Template.Spec.DNSConfig = &apiv1.PodDNSConfig{ + Nameservers: []string{nameServers}, + } + } jobDefinition.Spec.Template.Spec.Affinity = &apiv1.Affinity{ NodeAffinity: &apiv1.NodeAffinity{ RequiredDuringSchedulingIgnoredDuringExecution: &apiv1.NodeSelector{ @@ -480,6 +542,20 @@ func CreateChaincodeJob( } } + if len(hostAliases) > 0 { + logger.Debugf("Adding host aliases to job definition for chaincode ID %s", chaincodeData.ChaincodeID) + jobDefinition.Spec.Template.Spec.HostAliases = hostAliases + } + + if len(customAnnotations) > 0 { + logger.Debugf("Adding custom annotations to job definition for chaincode ID %s", chaincodeData.ChaincodeID) + + for k, v := range customAnnotations { + jobDefinition.ObjectMeta.Annotations[k] = v + jobDefinition.Spec.Template.ObjectMeta.Annotations[k] = v + } + } + jobName := jobDefinition.Name logger.Debugf( diff --git a/internal/util/k8s_test.go b/internal/util/k8s_test.go index ccad4d9..1515fa4 100644 --- a/internal/util/k8s_test.go +++ b/internal/util/k8s_test.go @@ -4,6 +4,7 @@ import ( "github.com/hyperledger-labs/fabric-builder-k8s/internal/util" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "strings" ) var _ = Describe("K8s", func() { @@ -130,4 +131,151 @@ var _ = Describe("K8s", func() { Expect(name).To(Equal("hlf-k8sbuilder-ftw-fabfabfabfabcarfabfabfabfabcar-b46p74k4ygwh6")) }) }) + + Describe("ParseAnnotations", func() { + It("should return empty map for empty string", func() { + result := util.ParseAnnotations("") + Expect(result).To(BeEmpty()) + }) + + It("should parse single annotation", func() { + result := util.ParseAnnotations("sidecar.istio.io/inject=true") + Expect(result).To(HaveLen(1)) + Expect(result["sidecar.istio.io/inject"]).To(Equal("true")) + }) + + It("should parse multiple annotations", func() { + result := util.ParseAnnotations("sidecar.istio.io/inject=true,app=myapp,version=1.0") + Expect(result).To(HaveLen(3)) + Expect(result["sidecar.istio.io/inject"]).To(Equal("true")) + Expect(result["app"]).To(Equal("myapp")) + Expect(result["version"]).To(Equal("1.0")) + }) + + It("should handle annotations with spaces", func() { + result := util.ParseAnnotations(" sidecar.istio.io/inject = true , app = myapp ") + Expect(result).To(HaveLen(2)) + Expect(result["sidecar.istio.io/inject"]).To(Equal("true")) + Expect(result["app"]).To(Equal("myapp")) + }) + + It("should skip invalid entries without equals sign", func() { + result := util.ParseAnnotations("sidecar.istio.io/inject=true,invalidentry,app=myapp") + Expect(result).To(HaveLen(2)) + Expect(result["sidecar.istio.io/inject"]).To(Equal("true")) + Expect(result["app"]).To(Equal("myapp")) + }) + + It("should skip empty entries", func() { + result := util.ParseAnnotations("sidecar.istio.io/inject=true,,app=myapp") + Expect(result).To(HaveLen(2)) + Expect(result["sidecar.istio.io/inject"]).To(Equal("true")) + Expect(result["app"]).To(Equal("myapp")) + }) + + It("should handle annotations with empty values", func() { + result := util.ParseAnnotations("sidecar.istio.io/inject=,app=myapp") + Expect(result).To(HaveLen(2)) + Expect(result["sidecar.istio.io/inject"]).To(Equal("")) + Expect(result["app"]).To(Equal("myapp")) + }) + + It("should handle annotations with equals signs in values", func() { + result := util.ParseAnnotations("config=key=value,app=myapp") + Expect(result).To(HaveLen(2)) + Expect(result["config"]).To(Equal("key=value")) + Expect(result["app"]).To(Equal("myapp")) + }) + + It("should skip entries with empty keys", func() { + result := util.ParseAnnotations("=value,app=myapp") + Expect(result).To(HaveLen(1)) + Expect(result["app"]).To(Equal("myapp")) + }) + }) + Describe("IsValidIPAddress", func() { + It("should return true for valid IPv4 addresses", func() { + Expect(util.IsValidIPAddress("192.168.1.1")).To(BeTrue()) + Expect(util.IsValidIPAddress("10.96.0.10")).To(BeTrue()) + Expect(util.IsValidIPAddress("8.8.8.8")).To(BeTrue()) + Expect(util.IsValidIPAddress("0.0.0.0")).To(BeTrue()) + Expect(util.IsValidIPAddress("255.255.255.255")).To(BeTrue()) + }) + + It("should return true for valid IPv6 addresses", func() { + Expect(util.IsValidIPAddress("2001:0db8:85a3:0000:0000:8a2e:0370:7334")).To(BeTrue()) + Expect(util.IsValidIPAddress("2001:db8::1")).To(BeTrue()) + Expect(util.IsValidIPAddress("::1")).To(BeTrue()) + Expect(util.IsValidIPAddress("fe80::")).To(BeTrue()) + }) + + It("should return false for invalid IP addresses", func() { + Expect(util.IsValidIPAddress("")).To(BeFalse()) + Expect(util.IsValidIPAddress("invalid")).To(BeFalse()) + Expect(util.IsValidIPAddress("256.1.1.1")).To(BeFalse()) + Expect(util.IsValidIPAddress("192.168.1")).To(BeFalse()) + Expect(util.IsValidIPAddress("192.168.1.1.1")).To(BeFalse()) + Expect(util.IsValidIPAddress("not-an-ip")).To(BeFalse()) + }) + }) + + Describe("IsValidAnnotationKey", func() { + It("should return true for valid annotation keys without prefix", func() { + Expect(util.IsValidAnnotationKey("app")).To(BeTrue()) + Expect(util.IsValidAnnotationKey("version")).To(BeTrue()) + Expect(util.IsValidAnnotationKey("my-annotation")).To(BeTrue()) + Expect(util.IsValidAnnotationKey("my_annotation")).To(BeTrue()) + Expect(util.IsValidAnnotationKey("my.annotation")).To(BeTrue()) + Expect(util.IsValidAnnotationKey("a1")).To(BeTrue()) + }) + + It("should return true for valid annotation keys with prefix", func() { + Expect(util.IsValidAnnotationKey("sidecar.istio.io/inject")).To(BeTrue()) + Expect(util.IsValidAnnotationKey("prometheus.io/scrape")).To(BeTrue()) + Expect(util.IsValidAnnotationKey("example.com/my-annotation")).To(BeTrue()) + Expect(util.IsValidAnnotationKey("sub.domain.example.com/key")).To(BeTrue()) + }) + + It("should return false for empty key", func() { + Expect(util.IsValidAnnotationKey("")).To(BeFalse()) + }) + + It("should return false for keys with invalid characters", func() { + Expect(util.IsValidAnnotationKey("invalid*key")).To(BeFalse()) + Expect(util.IsValidAnnotationKey("invalid key")).To(BeFalse()) + Expect(util.IsValidAnnotationKey("invalid@key")).To(BeFalse()) + }) + + It("should return false for keys starting with invalid characters", func() { + Expect(util.IsValidAnnotationKey("-key")).To(BeFalse()) + Expect(util.IsValidAnnotationKey(".key")).To(BeFalse()) + Expect(util.IsValidAnnotationKey("_key")).To(BeFalse()) + }) + + It("should return false for keys ending with invalid characters", func() { + Expect(util.IsValidAnnotationKey("key-")).To(BeFalse()) + Expect(util.IsValidAnnotationKey("key.")).To(BeFalse()) + Expect(util.IsValidAnnotationKey("key_")).To(BeFalse()) + }) + + It("should return false for keys with name longer than 63 characters", func() { + longName := strings.Repeat("a", 64) + Expect(util.IsValidAnnotationKey(longName)).To(BeFalse()) + }) + + It("should return false for keys with prefix longer than 253 characters", func() { + longPrefix := strings.Repeat("a", 254) + Expect(util.IsValidAnnotationKey(longPrefix + "/key")).To(BeFalse()) + }) + + It("should return false for keys with invalid prefix format", func() { + Expect(util.IsValidAnnotationKey("Invalid.Com/key")).To(BeFalse()) + Expect(util.IsValidAnnotationKey("-invalid.com/key")).To(BeFalse()) + Expect(util.IsValidAnnotationKey("invalid-.com/key")).To(BeFalse()) + }) + + It("should return false for keys with empty name after prefix", func() { + Expect(util.IsValidAnnotationKey("example.com/")).To(BeFalse()) + }) + }) }) diff --git a/test/testscript_helpers.go b/test/testscript_helpers.go index c95b042..19f117d 100644 --- a/test/testscript_helpers.go +++ b/test/testscript_helpers.go @@ -184,6 +184,17 @@ func podInfoCmd(script *testscript.TestScript, _ bool, args []string) { script.Check(err) } + // Output DNS configuration + _, err = script.Stdout().Write(fmt.Appendf(nil, "Pod DNS policy: %s\n", pod.Spec.DNSPolicy)) + script.Check(err) + + if pod.Spec.DNSConfig != nil { + for _, ns := range pod.Spec.DNSConfig.Nameservers { + _, err = script.Stdout().Write(fmt.Appendf(nil, "Pod DNS nameserver: %s\n", ns)) + script.Check(err) + } + } + if pod.Spec.Affinity != nil && pod.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution != nil { for _, t := range pod.Spec.Affinity.NodeAffinity.RequiredDuringSchedulingIgnoredDuringExecution.NodeSelectorTerms { for _, e := range t.MatchExpressions {