Merge pull request #2 from equinixmetal/sync-all

Sync all
This commit is contained in:
Mike Mason
2023-07-21 13:56:28 -05:00
committed by GitHub
60 changed files with 2626 additions and 512 deletions

82
.buildkite/pipeline.yml Normal file
View File

@@ -0,0 +1,82 @@
env:
ARTIFACT_NAME: bk-infra9-metal-bridge
BIN_NAME: infra9-metal-bridge
DEPLOYMENT_REPO: k8s-infra9-metal-bridge
GOPRIVATE: go.equinixmetal.net/*
IMAGE_TAG: ${BUILDKITE_BUILD_NUMBER}-${BUILDKITE_COMMIT:0:8}
QUAY_REPO: quay.io/equinixmetal/infra9-metal-bridge
steps:
- label: ":golangci-lint: lint"
key: "lint"
command: |
make lint
plugins:
- docker#v5.3.0:
image: "golangci/golangci-lint:v1.51.2"
volumes:
- "/var/lib/buildkite-agent/.gitconfig/:/root/.gitconfig/"
- label: ":test_tube: test"
key: "test"
command: |
make test
plugins:
- docker#v5.3.0:
image: "golang:1.20"
volumes:
- "/var/lib/buildkite-agent/.gitconfig/:/root/.gitconfig/"
- label: ":golang: build"
key: "gobuild"
commands: |
CGO_ENABLED=0 go build -o ${ARTIFACT_NAME} -buildvcs=false ./main.go
artifact_paths: "${ARTIFACT_NAME}"
plugins:
- docker#v5.6.0:
image: "golang:1.20"
volumes:
- "/var/lib/buildkite-agent/.gitconfig:/root/.gitconfig"
- label: ":whale: docker build"
key: "build"
depends_on: ["lint", "test", "gobuild"]
commands: |
#!/bin/bash
echo --- Retrieve artifacts
buildkite-agent artifact download "${ARTIFACT_NAME}" .
mv "${ARTIFACT_NAME}" "${BIN_NAME}"
# make sure it is executable
chmod +x ${BIN_NAME}
echo --- Build Docker Image
docker build . -t "$QUAY_REPO:$IMAGE_TAG"
echo --- Push Docker Image
docker push "$QUAY_REPO:$IMAGE_TAG"
buildkite-agent annotate --style "success" "Image pushed to quay [$QUAY_REPO:$IMAGE_TAG](https://$QUAY_REPO:$IMAGE_TAG)"
# For main commits, pull-requests will be created to bump the image in the deployment manifest
- label: "Bump image tag for main branch builds"
depends_on:
- "build"
if: build.branch == 'main'
plugins:
- first-aml/git-clone:
repository: git@github.com:equinixmetal/$DEPLOYMENT_REPO.git
- ssh://git@github.com/equinixmetal/ssm-buildkite-plugin#v1.0.3:
parameters:
GITHUB_TOKEN: /buildkite/github/personal-access-token/v1
- ssh://git@github.com/packethost/yaml-update-buildkite-plugin#v1.0.1:
dir: $DEPLOYMENT_REPO
file: values.yaml
values:
- .bridge.image.tag=$IMAGE_TAG
- ssh://git@github.com/equinixmetal/github-pr-template-buildkite-plugin#v0.2.0: {}
# Create Pull Request to main using commit from previous step
- envato/github-pull-request#v0.4.0:
title: '[buildkite] bump image tag to $IMAGE_TAG'
head: buildkite-yaml-update-$BUILDKITE_BUILD_NUMBER
base: main

56
.golangci.yml Normal file
View File

@@ -0,0 +1,56 @@
linters-settings:
gofumpt:
extra-rules: true
linters:
enable:
# default linters
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- typecheck
- unused
# additional linters
- bodyclose
- gocritic
- gocyclo
# - goerr113
- gofmt
- goimports
- revive
- gomnd
- govet
- misspell
- noctx
- stylecheck
- whitespace
- wsl
# - bod
issues:
exclude:
# Default excludes from `golangci-lint run --help` with EXC0002 removed
# EXC0001 errcheck: Almost all programs ignore errors on these functions and in most cases it's ok
- Error return value of .((os\.)?std(out|err)\..*|.*Close|.*Flush|os\.Remove(All)?|.*print(f|ln)?|os\.(Un)?Setenv). is not checked
# EXC0002 golint: Annoying issue about not having a comment. The rare codebase has such comments
# - (comment on exported (method|function|type|const)|should have( a package)? comment|comment should be of the form)
# EXC0003 golint: False positive when tests are defined in package 'test'
- func name will be used as test\.Test.* by other packages, and that stutters; consider calling this
# EXC0004 govet: Common false positives
- (possible misuse of unsafe.Pointer|should have signature)
# EXC0005 staticcheck: Developers tend to write in C-style with an explicit 'break' in a 'switch', so it's ok to ignore
- ineffective break statement. Did you mean to break out of the outer loop
# EXC0006 gosec: Too many false-positives on 'unsafe' usage
- Use of unsafe calls should be audited
# EXC0007 gosec: Too many false-positives for parametrized shell calls
- Subprocess launch(ed with variable|ing should be audited)
# EXC0008 gosec: Duplicated errcheck checks
- (G104|G307)
# EXC0009 gosec: Too many issues in popular repos
- (Expect directory permissions to be 0750 or less|Expect file permissions to be 0600 or less)
# EXC0010 gosec: False positive is triggered by 'src, err := ioutil.ReadFile(filename)'
- Potential file inclusion via variable
exclude-use-default: false

60
Makefile Normal file
View File

@@ -0,0 +1,60 @@
GOPKG=go.equinixmetal.net/infra9-metal-bridge
ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
TOOLS_DIR := .tools
GO_FILES=$(shell git ls-files '*.go')
GOTOOLS_REPO = golang.org/x/tools
GOTOOLS_VERSION = v0.9.3
GOLANGCI_LINT_REPO = github.com/golangci/golangci-lint
GOLANGCI_LINT_VERSION = v1.51.2
.PHONY: all help lint test golint fix-lint unit-test coverage
help: Makefile ## Print help.
@grep -h "##" $(MAKEFILE_LIST) | grep -v grep | sed -e 's/:.*##/#/' | column -c 2 -t -s#
all: lint test ## Lints and tests.
lint: golint ## Runs all lint checks.
golint: | $(TOOLS_DIR)/golangci-lint ## Runs Go lint checks.
@echo Linting Go files...
@$(TOOLS_DIR)/golangci-lint run --timeout=5m
fix-lint: $(GO_FILES) | $(TOOLS_DIR)/goimports ## Runs goimports on all go files.
@echo Linting go files...
@$(TOOLS_DIR)/goimports -w -local $(GOPKG) $^
test: | unit-test ## Regenerate files and run unit tests.
unit-test: ## Runs unit tests.
@echo Running unit tests...
@go test -timeout 30s -cover -short ./...
coverage: ## Generates a test coverage report.
@echo Generating coverage report...
@go test -timeout 30s ./... -coverprofile=coverage.out -covermode=atomic
@go tool cover -func=coverage.out
@go tool cover -html=coverage.out
bin: ## Builds the binary
@echo Building binary
@go build -o infra9-metal-bridge ./main.go
# Tools setup
$(TOOLS_DIR):
mkdir -p $(TOOLS_DIR)
$(TOOLS_DIR)/goimports: | $(TOOLS_DIR)
@echo "Installing $(GOTOOLS_REPO)/cmd/goimports@$(GOTOOLS_VERSION)"
@GOBIN=$(ROOT_DIR)/$(TOOLS_DIR) go install $(GOTOOLS_REPO)/cmd/goimports@$(GOTOOLS_VERSION)
$(TOOLS_DIR)/golangci-lint: | $(TOOLS_DIR)
@echo "Installing $(GOLANGCI_LINT_REPO)/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)"
@GOBIN=$(ROOT_DIR)/$(TOOLS_DIR) go install $(GOLANGCI_LINT_REPO)/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
$@ version
$@ linters

View File

@@ -5,6 +5,7 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"go.infratographer.com/x/echox" "go.infratographer.com/x/echox"
"go.infratographer.com/x/events" "go.infratographer.com/x/events"
"go.infratographer.com/x/oauth2x"
"go.infratographer.com/x/otelx" "go.infratographer.com/x/otelx"
"go.infratographer.com/x/versionx" "go.infratographer.com/x/versionx"
"go.infratographer.com/x/viperx" "go.infratographer.com/x/viperx"
@@ -13,7 +14,6 @@ import (
"go.equinixmetal.net/infra9-metal-bridge/internal/metal" "go.equinixmetal.net/infra9-metal-bridge/internal/metal"
"go.equinixmetal.net/infra9-metal-bridge/internal/permissions" "go.equinixmetal.net/infra9-metal-bridge/internal/permissions"
"go.equinixmetal.net/infra9-metal-bridge/internal/pubsub" "go.equinixmetal.net/infra9-metal-bridge/internal/pubsub"
"go.equinixmetal.net/infra9-metal-bridge/internal/routes"
"go.equinixmetal.net/infra9-metal-bridge/internal/service" "go.equinixmetal.net/infra9-metal-bridge/internal/service"
) )
@@ -33,9 +33,11 @@ func init() {
events.MustViperFlagsForSubscriber(viper.GetViper(), serveCmd.Flags()) events.MustViperFlagsForSubscriber(viper.GetViper(), serveCmd.Flags())
serveCmd.PersistentFlags().StringSlice("events-topics", []string{}, "event topics to subscribe to") serveCmd.PersistentFlags().StringSlice("events-topics", []string{}, "event topics to subscribe to")
viperx.MustBindFlag(viper.GetViper(), "events.topics", serveCmd.PersistentFlags().Lookup("events-topics")) viperx.MustBindFlag(viper.GetViper(), "events.subscriber.topics", serveCmd.PersistentFlags().Lookup("events-topics"))
oauth2x.MustViperFlags(viper.GetViper(), serveCmd.Flags())
permissions.MustViperFlags(viper.GetViper(), serveCmd.Flags()) permissions.MustViperFlags(viper.GetViper(), serveCmd.Flags())
metal.MustViperFlags(viper.GetViper(), serveCmd.Flags())
} }
func serve(cmd *cobra.Command, _ []string) { func serve(cmd *cobra.Command, _ []string) {
@@ -57,9 +59,21 @@ func serve(cmd *cobra.Command, _ []string) {
logger.Fatalw("error initializing Metal client", "error", err) logger.Fatalw("error initializing Metal client", "error", err)
} }
permHTTPClient := permissions.DefaultHTTPClient
if config.AppConfig.OIDC.Client.ID != "" {
tokenSrc, err := oauth2x.NewClientCredentialsTokenSrc(cmd.Context(), config.AppConfig.OIDC.Client)
if err != nil {
logger.Fatalw("error initializing oauth2 client", "error", err)
}
permHTTPClient = oauth2x.NewClient(cmd.Context(), tokenSrc)
}
perms, err := permissions.NewClient("", perms, err := permissions.NewClient("",
permissions.WithLogger(logger), permissions.WithLogger(logger),
permissions.WithConfig(config.AppConfig.Permissions), permissions.WithConfig(config.AppConfig.Permissions),
permissions.WithHTTPClient(permHTTPClient),
) )
if err != nil { if err != nil {
logger.Fatalw("error initializing Permissions client", "error", err) logger.Fatalw("error initializing Permissions client", "error", err)
@@ -74,27 +88,19 @@ func serve(cmd *cobra.Command, _ []string) {
logger.Fatalw("error initializing service", "error", err) logger.Fatalw("error initializing service", "error", err)
} }
subscriber, err := pubsub.NewSubscriber(cmd.Context(), config.AppConfig.Events.Subscriber, service, subscriber, err := pubsub.NewSubscriber(cmd.Context(), config.AppConfig.Events.Subscriber.SubscriberConfig, service,
pubsub.WithLogger(logger), pubsub.WithLogger(logger),
) )
if err != nil { if err != nil {
logger.Fatalw("unable to initialize event subscriber", "error", err) logger.Fatalw("unable to initialize event subscriber", "error", err)
} }
for _, topic := range viper.GetStringSlice("events.topics") { for _, topic := range config.AppConfig.Events.Subscriber.Topics {
if err := subscriber.Subscribe(topic); err != nil { if err := subscriber.Subscribe(topic); err != nil {
logger.Fatalw("error subscribing to topic: "+topic, "topic", topic, "error", err) logger.Fatalw("error subscribing to topic: "+topic, "topic", topic, "error", err)
} }
} }
router, err := routes.NewRouter(
routes.WithLogger(logger.Desugar()),
routes.WithService(service),
)
if err != nil {
logger.Fatalw("error initializing router", "error", err)
}
srv, err := echox.NewServer( srv, err := echox.NewServer(
logger.Desugar(), logger.Desugar(),
echox.ConfigFromViper(viper.GetViper()), echox.ConfigFromViper(viper.GetViper()),
@@ -104,8 +110,6 @@ func serve(cmd *cobra.Command, _ []string) {
logger.Fatalw("failed to initialize new server", "error", err) logger.Fatalw("failed to initialize new server", "error", err)
} }
srv.AddHandler(router)
defer subscriber.Close() defer subscriber.Close()
logger.Info("Listening for events") logger.Info("Listening for events")

View File

@@ -1,7 +1,13 @@
oidc:
client:
id: idntcli-abc123
secret: somesecret
issuer: http://mock-oauth2-server:8081/default
events: events:
subscriber:
topics: topics:
- '*.*' - '*.*'
subscriber:
url: nats://nats:4222 url: nats://nats:4222
prefix: com.equinixmetal prefix: com.equinixmetal
queueGroup: metal-bridge queueGroup: metal-bridge

30
go.mod
View File

@@ -5,13 +5,16 @@ go 1.20
require ( require (
github.com/ThreeDotsLabs/watermill v1.2.0 github.com/ThreeDotsLabs/watermill v1.2.0
github.com/labstack/echo/v4 v4.10.2 github.com/labstack/echo/v4 v4.10.2
github.com/nats-io/nats.go v1.26.0 github.com/nats-io/nats.go v1.27.1
github.com/spf13/cobra v1.7.0 github.com/spf13/cobra v1.7.0
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.16.0 github.com/spf13/viper v1.16.0
go.infratographer.com/x v0.3.2 github.com/stretchr/testify v1.8.4
go.infratographer.com/x v0.3.3
go.opentelemetry.io/otel v1.16.0 go.opentelemetry.io/otel v1.16.0
go.opentelemetry.io/otel/trace v1.16.0 go.opentelemetry.io/otel/trace v1.16.0
go.uber.org/zap v1.24.0 go.uber.org/zap v1.24.0
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1
) )
require ( require (
@@ -20,6 +23,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/garsue/watermillzap v1.2.0 // indirect github.com/garsue/watermillzap v1.2.0 // indirect
github.com/go-logr/logr v1.2.4 // indirect github.com/go-logr/logr v1.2.4 // indirect
@@ -33,14 +37,14 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jaevor/go-nanoid v1.3.0 // indirect github.com/jaevor/go-nanoid v1.3.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.16.5 // indirect github.com/klauspost/compress v1.16.7 // indirect
github.com/labstack/echo-contrib v0.15.0 // indirect github.com/labstack/echo-contrib v0.15.0 // indirect
github.com/labstack/echo-jwt/v4 v4.2.0 // indirect github.com/labstack/echo-jwt/v4 v4.2.0 // indirect
github.com/labstack/gommon v0.4.0 // indirect github.com/labstack/gommon v0.4.0 // indirect
github.com/lithammer/shortuuid/v3 v3.0.7 // indirect github.com/lithammer/shortuuid/v3 v3.0.7 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect github.com/mattn/go-isatty v0.0.19 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -50,14 +54,15 @@ require (
github.com/oklog/ulid v1.3.1 // indirect github.com/oklog/ulid v1.3.1 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.14.0 // indirect github.com/prometheus/client_golang v1.14.0 // indirect
github.com/prometheus/client_model v0.3.0 // indirect github.com/prometheus/client_model v0.3.0 // indirect
github.com/prometheus/common v0.40.0 // indirect github.com/prometheus/common v0.40.0 // indirect
github.com/prometheus/procfs v0.9.0 // indirect github.com/prometheus/procfs v0.11.0 // indirect
github.com/spf13/afero v1.9.5 // indirect github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cast v1.5.1 // indirect github.com/spf13/cast v1.5.1 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/objx v0.5.0 // indirect
github.com/subosito/gotenv v1.4.2 // indirect github.com/subosito/gotenv v1.4.2 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
@@ -73,15 +78,16 @@ require (
go.opentelemetry.io/proto/otlp v0.19.0 // indirect go.opentelemetry.io/proto/otlp v0.19.0 // indirect
go.uber.org/atomic v1.10.0 // indirect go.uber.org/atomic v1.10.0 // indirect
go.uber.org/multierr v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect
golang.org/x/crypto v0.9.0 // indirect golang.org/x/crypto v0.11.0 // indirect
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect golang.org/x/net v0.12.0 // indirect
golang.org/x/net v0.10.0 // indirect golang.org/x/oauth2 v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect golang.org/x/sys v0.10.0 // indirect
golang.org/x/text v0.9.0 // indirect golang.org/x/text v0.11.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/time v0.3.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
google.golang.org/grpc v1.55.0 // indirect google.golang.org/grpc v1.55.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

50
go.sum
View File

@@ -49,7 +49,7 @@ github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kd
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/brianvoe/gofakeit/v6 v6.21.0 h1:tNkm9yxEbpuPK8Bx39tT4sSc5i9SUGiciLdNix+VDQY= github.com/brianvoe/gofakeit/v6 v6.23.0 h1:pgVhyWpYq4e0GEVCh2gdZnS/nBX+8SnyTBliHg5xjks=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -187,8 +187,8 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I=
github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -212,8 +212,8 @@ github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxec
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g= github.com/minio/highwayhash v1.0.2 h1:Aak5U0nElisjDCfPSG79Tgzkn2gl66NxOMspRrKnA/g=
@@ -226,8 +226,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nats-io/jwt/v2 v2.4.1 h1:Y35W1dgbbz2SQUYDPCaclXcuqleVmpbRa7646Jf2EX4= github.com/nats-io/jwt/v2 v2.4.1 h1:Y35W1dgbbz2SQUYDPCaclXcuqleVmpbRa7646Jf2EX4=
github.com/nats-io/nats-server/v2 v2.9.17 h1:gFpUQ3hqIDJrnqog+Bl5vaXg+RhhYEZIElasEuRn2tw= github.com/nats-io/nats-server/v2 v2.9.17 h1:gFpUQ3hqIDJrnqog+Bl5vaXg+RhhYEZIElasEuRn2tw=
github.com/nats-io/nats.go v1.26.0 h1:fWJTYPnZ8DzxIaqIHOAMfColuznchnd5Ab5dbJpgPIE= github.com/nats-io/nats.go v1.27.1 h1:OuYnal9aKVSnOzLQIzf7554OXMCG7KbaTkCSBHRcSoo=
github.com/nats-io/nats.go v1.26.0/go.mod h1:XpbWUlOElGwTYbMR7imivs7jJj9GtK7ypv321Wp6pjc= github.com/nats-io/nats.go v1.27.1/go.mod h1:XpbWUlOElGwTYbMR7imivs7jJj9GtK7ypv321Wp6pjc=
github.com/nats-io/nkeys v0.4.4 h1:xvBJ8d69TznjcQl9t6//Q5xXuVhyYiSos6RPtvQNTwA= github.com/nats-io/nkeys v0.4.4 h1:xvBJ8d69TznjcQl9t6//Q5xXuVhyYiSos6RPtvQNTwA=
github.com/nats-io/nkeys v0.4.4/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= github.com/nats-io/nkeys v0.4.4/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -248,8 +248,8 @@ github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvq
github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w=
github.com/prometheus/common v0.40.0 h1:Afz7EVRqGg2Mqqf4JuF9vdvp1pi220m55Pi9T2JnO4Q= github.com/prometheus/common v0.40.0 h1:Afz7EVRqGg2Mqqf4JuF9vdvp1pi220m55Pi9T2JnO4Q=
github.com/prometheus/common v0.40.0/go.mod h1:L65ZJPSmfn/UBWLQIHV7dBrKFidB/wPlF1y5TlSt9OE= github.com/prometheus/common v0.40.0/go.mod h1:L65ZJPSmfn/UBWLQIHV7dBrKFidB/wPlF1y5TlSt9OE=
github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= github.com/prometheus/procfs v0.11.0 h1:5EAgkfkMl659uZPbe9AS2N68a7Cc1TJbPEuGzFuRbyk=
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= github.com/prometheus/procfs v0.11.0/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
@@ -280,6 +280,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -291,8 +292,8 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.infratographer.com/x v0.3.2 h1:AxHY77AGhWcRNcO7ENP/4Cj0xg6KGKpxFn/yZn+rPOs= go.infratographer.com/x v0.3.3 h1:dTaLEp75RgL0JxKJhrcuQTP4a2x/MrevvZ3OdtkEhCs=
go.infratographer.com/x v0.3.2/go.mod h1:GvOhGwi/1Dp5qAQSudHUdLfFmiXzzc27KBfkH0nxnEQ= go.infratographer.com/x v0.3.3/go.mod h1:pXXSdeJBisAK3AdED5EFj7Yo8z8td7fOWDkNl4Dkp0s=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -339,8 +340,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -351,8 +352,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -409,8 +410,8 @@ golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -421,7 +422,8 @@ golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.8.0 h1:6dkIjl3j3LtZ/O3sTgZTMsLKSftL/B8Zgq4huOIIUu8= golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8=
golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -475,8 +477,8 @@ golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -487,8 +489,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -647,8 +649,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

View File

@@ -5,6 +5,7 @@ import (
"go.infratographer.com/x/echox" "go.infratographer.com/x/echox"
"go.infratographer.com/x/events" "go.infratographer.com/x/events"
"go.infratographer.com/x/loggingx" "go.infratographer.com/x/loggingx"
"go.infratographer.com/x/oauth2x"
"go.infratographer.com/x/otelx" "go.infratographer.com/x/otelx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal" "go.equinixmetal.net/infra9-metal-bridge/internal/metal"
@@ -15,6 +16,7 @@ import (
// AppConfig is the main application configuration. // AppConfig is the main application configuration.
var AppConfig struct { var AppConfig struct {
Logging loggingx.Config Logging loggingx.Config
OIDC OIDCClientConfig
EquinixMetal metal.Config EquinixMetal metal.Config
OTel otelx.Config OTel otelx.Config
Server echox.Config Server echox.Config
@@ -27,5 +29,16 @@ var AppConfig struct {
// EventsConfig defines the configuration setting up both subscriptions and publishing // EventsConfig defines the configuration setting up both subscriptions and publishing
type EventsConfig struct { type EventsConfig struct {
Publisher events.PublisherConfig Publisher events.PublisherConfig
Subscriber events.SubscriberConfig Subscriber SubscriberConfig
}
// OIDCClientConfig defines the configuration for OIDC Client Credentials.
type OIDCClientConfig struct {
Client oauth2x.Config
}
// SubscriberConfig extends events SubscriberConfig by adding topics.
type SubscriberConfig struct {
events.SubscriberConfig `mapstructure:",squash"`
Topics []string
} }

View File

@@ -1,6 +1,9 @@
package metal package metal
import ( import (
"github.com/spf13/pflag"
"github.com/spf13/viper"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emapi" "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emapi"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emgql" "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emgql"
) )
@@ -13,3 +16,9 @@ type Config struct {
// EMAPI sets the provider to Equinix Metal API. // EMAPI sets the provider to Equinix Metal API.
EMAPI emapi.Config EMAPI emapi.Config
} }
// MustViperFlags registers command flags along with the viper bindings.
func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) {
emgql.MustViperFlags(v, flags)
emapi.MustViperFlags(v, flags)
}

View File

@@ -5,4 +5,7 @@ import "errors"
var ( var (
// ErrUnauthorized is returned when the token provided did not validate to a user. // ErrUnauthorized is returned when the token provided did not validate to a user.
ErrUnauthorized = errors.New("unauthorized key") ErrUnauthorized = errors.New("unauthorized key")
// ErrMetalProviderRequired is returned when no provider has been configured for the metal client.
ErrMetalProviderRequired = errors.New("metal provider required")
) )

View File

@@ -3,34 +3,53 @@ package metal
import ( import (
"context" "context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
"go.infratographer.com/x/gidx" "go.infratographer.com/x/gidx"
"go.uber.org/zap" "go.uber.org/zap"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
) )
// Client is the Equinix Metal API Client struct. // Client is the Equinix Metal Client Interface.
type Client struct { type Client interface {
logger *zap.Logger providers.Provider
provider provider.Provider
} }
func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) { // client is the Equinix Metal Client struct.
type client struct {
logger *zap.Logger
provider providers.Provider
}
// GetOrganizationDetails fetches the organization id provided with its memberships.
func (c *client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) {
return c.provider.GetOrganizationDetails(ctx, id) return c.provider.GetOrganizationDetails(ctx, id)
} }
func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) { // GetProjectDetails fetchs the provided project id with membership information.
func (c *client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) {
return c.provider.GetProjectDetails(ctx, id) return c.provider.GetProjectDetails(ctx, id)
} }
func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) { // GetUserDetails fetches the provided user id.
func (c *client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) {
return c.provider.GetUserDetails(ctx, id) return c.provider.GetUserDetails(ctx, id)
} }
// New creates a new Client. // GetUserOrganizationRole returns the role for the user in the organization.
func New(options ...Option) (*Client, error) { func (c *client) GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) {
client := new(Client) return c.provider.GetUserOrganizationRole(ctx, userID, orgID)
}
// GetUserProjectRole returns the role for the user in the project.
func (c *client) GetUserProjectRole(ctx context.Context, userID, projID gidx.PrefixedID) (string, error) {
return c.provider.GetUserProjectRole(ctx, userID, projID)
}
// New creates a new Equinix Metal Client.
func New(options ...Option) (Client, error) {
client := new(client)
for _, opt := range options { for _, opt := range options {
if err := opt(client); err != nil { if err := opt(client); err != nil {
@@ -42,5 +61,9 @@ func New(options ...Option) (*Client, error) {
client.logger = zap.NewNop() client.logger = zap.NewNop()
} }
if client.provider == nil {
return nil, ErrMetalProviderRequired
}
return client, nil return client, nil
} }

View File

@@ -0,0 +1,2 @@
// Package models defines generic models each provider must be able to return.
package models

View File

@@ -9,4 +9,7 @@ const (
// IDPrefixUser defines the ID Prefix for a User. // IDPrefixUser defines the ID Prefix for a User.
IDPrefixUser = "metlusr" IDPrefixUser = "metlusr"
// IdentityPrefixUser defines the ID Prefix for a User created with Identity API.
IdentityPrefixUser = "idntusr"
) )

View File

@@ -1,5 +1,6 @@
package models package models
// Membership contains metal membership details.
type Membership[T any] struct { type Membership[T any] struct {
ID string `json:"id"` ID string `json:"id"`
User *UserDetails `json:"user"` User *UserDetails `json:"user"`

View File

@@ -2,6 +2,7 @@ package models
import "go.infratographer.com/x/gidx" import "go.infratographer.com/x/gidx"
// OrganizationDetails contains the organization and membership information.
type OrganizationDetails struct { type OrganizationDetails struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -9,6 +10,7 @@ type OrganizationDetails struct {
Projects []*ProjectDetails `json:"projects"` Projects []*ProjectDetails `json:"projects"`
} }
// PrefixedID returns the prefixed id for the organization.
func (d *OrganizationDetails) PrefixedID() gidx.PrefixedID { func (d *OrganizationDetails) PrefixedID() gidx.PrefixedID {
if d.ID == "" { if d.ID == "" {
return gidx.NullPrefixedID return gidx.NullPrefixedID

View File

@@ -2,6 +2,7 @@ package models
import "go.infratographer.com/x/gidx" import "go.infratographer.com/x/gidx"
// ProjectDetails contains project and membership information.
type ProjectDetails struct { type ProjectDetails struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -9,6 +10,7 @@ type ProjectDetails struct {
Organization *OrganizationDetails `json:"organization"` Organization *OrganizationDetails `json:"organization"`
} }
// PrefixedID returns the prefixed id for the project.
func (d *ProjectDetails) PrefixedID() gidx.PrefixedID { func (d *ProjectDetails) PrefixedID() gidx.PrefixedID {
if d.ID == "" { if d.ID == "" {
return gidx.NullPrefixedID return gidx.NullPrefixedID

View File

@@ -1,12 +1,23 @@
package models package models
import "go.infratographer.com/x/gidx" import (
"crypto/sha256"
"encoding/base64"
const ( "go.infratographer.com/x/gidx"
MetalUserPrefix = "metlusr"
) )
const (
// MetalUserIssuer is the issuer that is used for metal api token users.
MetalUserIssuer = "https://auth.equinix.com/"
// MetalUserIssuerIDPrefix is the issuer id prefix added by the issuer.
MetalUserIssuerIDPrefix = "auth0|"
)
// UserDetails contains the user information.
type UserDetails struct { type UserDetails struct {
id *gidx.PrefixedID
ID string `json:"id"` ID string `json:"id"`
FullName string `json:"full_name"` FullName string `json:"full_name"`
Organizations []*OrganizationDetails `json:"organizations"` Organizations []*OrganizationDetails `json:"organizations"`
@@ -14,10 +25,40 @@ type UserDetails struct {
Roles []string `json:"roles"` Roles []string `json:"roles"`
} }
// PrefixedID returns the identity prefixed id for the user.
func (d *UserDetails) PrefixedID() gidx.PrefixedID { func (d *UserDetails) PrefixedID() gidx.PrefixedID {
if d.ID == "" { if d.id != nil {
return gidx.NullPrefixedID return *d.id
} }
return gidx.PrefixedID(IDPrefixUser + "-" + d.ID) nullID := gidx.NullPrefixedID
d.id = &nullID
if d.ID == "" {
return nullID
}
id, err := GenerateSubjectID(IdentityPrefixUser, MetalUserIssuer, MetalUserIssuerIDPrefix+d.ID)
if err != nil {
return nullID
}
d.id = &id
return *d.id
}
// GenerateSubjectID builds a identity prefixed id with the provided prefix for the issuer and subject.
func GenerateSubjectID(prefix, iss, sub string) (gidx.PrefixedID, error) {
// Concatenate the iss and sub values, then hash them
issSub := iss + sub
issSubHash := sha256.Sum256([]byte(issSub))
digest := base64.RawURLEncoding.EncodeToString(issSubHash[:])
// Concatenate the prefix with the digest
out := prefix + "-" + digest
return gidx.Parse(out)
} }

View File

@@ -1,18 +1,19 @@
package metal package metal
import ( import (
provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" "go.uber.org/zap"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emapi" "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emapi"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emgql" "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers/emgql"
"go.uber.org/zap"
) )
// Option is a Client configuration Option definition. // Option is a Client configuration Option definition.
type Option func(c *Client) error type Option func(c *client) error
// WithProvider sets the provider on the client. // WithProvider sets the provider on the client.
func WithProvider(provider provider.Provider) Option { func WithProvider(provider providers.Provider) Option {
return func(c *Client) error { return func(c *client) error {
c.provider = provider c.provider = provider
return nil return nil
@@ -21,7 +22,7 @@ func WithProvider(provider provider.Provider) Option {
// WithLogger sets the logger for the client. // WithLogger sets the logger for the client.
func WithLogger(logger *zap.Logger) Option { func WithLogger(logger *zap.Logger) Option {
return func(c *Client) error { return func(c *client) error {
c.logger = logger c.logger = logger
return nil return nil
@@ -30,7 +31,7 @@ func WithLogger(logger *zap.Logger) Option {
// WithConfig applies all configurations defined in the config. // WithConfig applies all configurations defined in the config.
func WithConfig(config Config) Option { func WithConfig(config Config) Option {
return func(c *Client) error { return func(c *client) error {
var options []Option var options []Option
if config.EMGQL.Populated() { if config.EMGQL.Populated() {

View File

@@ -12,7 +12,7 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers" "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
) )
const ( const (
@@ -25,12 +25,14 @@ const (
staffHeaderValue = "true" staffHeaderValue = "true"
) )
// DefaultHTTPClient is the default http client used if no client is provided.
var DefaultHTTPClient = &http.Client{ var DefaultHTTPClient = &http.Client{
Timeout: defaultHTTPTimeout, Timeout: defaultHTTPTimeout,
} }
var _ provider.Provider = &Client{} var _ providers.Provider = &Client{}
// Client is the client to interact with the equinix metal api.
type Client struct { type Client struct {
logger *zap.SugaredLogger logger *zap.SugaredLogger
httpClient *http.Client httpClient *http.Client
@@ -39,6 +41,8 @@ type Client struct {
consumerToken string consumerToken string
} }
// Do executes the provided request.
// If the out value is provided, the response will attempt to be json decoded.
func (c *Client) Do(req *http.Request, out any) (*http.Response, error) { func (c *Client) Do(req *http.Request, out any) (*http.Response, error) {
if c.authToken != "" { if c.authToken != "" {
req.Header.Set(authHeader, c.authToken) req.Header.Set(authHeader, c.authToken)
@@ -68,6 +72,7 @@ func (c *Client) Do(req *http.Request, out any) (*http.Response, error) {
return resp, nil return resp, nil
} }
// DoRequest creates a new request from the provided parameters and executes the request.
func (c *Client) DoRequest(ctx context.Context, method, path string, body io.Reader, out any) (*http.Response, error) { func (c *Client) DoRequest(ctx context.Context, method, path string, body io.Reader, out any) (*http.Response, error) {
path = strings.TrimPrefix(path, c.baseURL.Path) path = strings.TrimPrefix(path, c.baseURL.Path)

View File

@@ -3,6 +3,10 @@ package emapi
import ( import (
"fmt" "fmt"
"net/url" "net/url"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"go.infratographer.com/x/viperx"
) )
// Config provides configuration for connecting to the Equinix Metal API provider. // Config provides configuration for connecting to the Equinix Metal API provider.
@@ -17,6 +21,19 @@ type Config struct {
ConsumerToken string ConsumerToken string
} }
// MustViperFlags registers command flags along with the viper bindings.
func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) {
flags.String("emapi-base-url", "", "Equinix Metal Rest API Base URL")
viperx.MustBindFlag(v, "equinixmetal.emapi.baseurl", flags.Lookup("emapi-base-url"))
flags.String("emapi-auth-token", "", "Equinix Metal Rest Auth Token")
viperx.MustBindFlag(v, "equinixmetal.emapi.authtoken", flags.Lookup("emapi-auth-token"))
flags.String("emapi-consumer-token", "", "Equinix Metal Rest Consumer Token")
viperx.MustBindFlag(v, "equinixmetal.emapi.consumertoken", flags.Lookup("emapi-consumer-token"))
}
// Populated checks if any field has been populated.
func (c Config) Populated() bool { func (c Config) Populated() bool {
return c.AuthToken != "" || c.ConsumerToken != "" || c.BaseURL != "" return c.AuthToken != "" || c.ConsumerToken != "" || c.BaseURL != ""
} }

View File

@@ -0,0 +1,2 @@
// Package emapi implement a metal provider which fetches details from the Equinix Metal API.
package emapi

View File

@@ -2,4 +2,5 @@ package emapi
import "errors" import "errors"
// ErrBaseURLRequired is returned if no base url is provided.
var ErrBaseURLRequired = errors.New("emapi base url required") var ErrBaseURLRequired = errors.New("emapi base url required")

View File

@@ -0,0 +1,27 @@
package emapi
import (
"net/url"
"strings"
)
// idOrLinkID returns the id if not empty, otherwise it plucks the last subpath from the link.
// An empty string is returned if nothing is defined or an error occurs.
func idOrLinkID(id, link string) string {
if id != "" || link == "" {
return id
}
url, err := url.Parse(link)
if err != nil {
return ""
}
parts := strings.Split(strings.TrimRight(url.Path, "/"), "/")
if len(parts) != 0 {
return parts[len(parts)-1]
}
return ""
}

View File

@@ -4,10 +4,13 @@ import (
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
) )
// Roles contains a list of roles.
type Roles []string type Roles []string
// Memberships contains a list of memberships
type Memberships []*Membership type Memberships []*Membership
// ToDetailsWithOrganizationDetails converts the memberships to generic membership models with organization details.
func (m Memberships) ToDetailsWithOrganizationDetails(orgDetails *models.OrganizationDetails) []*models.Membership[models.OrganizationDetails] { func (m Memberships) ToDetailsWithOrganizationDetails(orgDetails *models.OrganizationDetails) []*models.Membership[models.OrganizationDetails] {
memberships := make([]*models.Membership[models.OrganizationDetails], len(m)) memberships := make([]*models.Membership[models.OrganizationDetails], len(m))
@@ -28,6 +31,7 @@ func (m Memberships) ToDetailsWithOrganizationDetails(orgDetails *models.Organiz
return memberships return memberships
} }
// ToDetailsWithProjectDetails converts the memberships to generic membership models with project details.
func (m Memberships) ToDetailsWithProjectDetails(projDetails *models.ProjectDetails) []*models.Membership[models.ProjectDetails] { func (m Memberships) ToDetailsWithProjectDetails(projDetails *models.ProjectDetails) []*models.Membership[models.ProjectDetails] {
memberships := make([]*models.Membership[models.ProjectDetails], len(m)) memberships := make([]*models.Membership[models.ProjectDetails], len(m))
@@ -48,15 +52,15 @@ func (m Memberships) ToDetailsWithProjectDetails(projDetails *models.ProjectDeta
return memberships return memberships
} }
// Membership contains membership information.
type Membership struct { type Membership struct {
client *Client
HREF string `json:"href"` HREF string `json:"href"`
ID string `json:"id"` ID string `json:"id"`
Roles Roles `json:"roles"` Roles Roles `json:"roles"`
User *User `json:"user"` User *User `json:"user"`
} }
// ToDetailsWithOrganizationDetails converts the membership to generic membership model with organization details.
func (m *Membership) ToDetailsWithOrganizationDetails(orgDetails *models.OrganizationDetails) *models.Membership[models.OrganizationDetails] { func (m *Membership) ToDetailsWithOrganizationDetails(orgDetails *models.OrganizationDetails) *models.Membership[models.OrganizationDetails] {
if m.ID == "" { if m.ID == "" {
return nil return nil
@@ -70,6 +74,7 @@ func (m *Membership) ToDetailsWithOrganizationDetails(orgDetails *models.Organiz
} }
} }
// ToDetailsWithProjectDetails converts the membership to generic membership model with organization details.
func (m *Membership) ToDetailsWithProjectDetails(projDetails *models.ProjectDetails) *models.Membership[models.ProjectDetails] { func (m *Membership) ToDetailsWithProjectDetails(projDetails *models.ProjectDetails) *models.Membership[models.ProjectDetails] {
if m.ID == "" { if m.ID == "" {
return nil return nil

View File

@@ -14,8 +14,10 @@ const (
organizationsPath = "/organizations" organizationsPath = "/organizations"
) )
// Organizations contains a list of organizations.
type Organizations []*Organization type Organizations []*Organization
// ToDetails converts to a generic model organization details.
func (o Organizations) ToDetails() []*models.OrganizationDetails { func (o Organizations) ToDetails() []*models.OrganizationDetails {
orgs := make([]*models.OrganizationDetails, len(o)) orgs := make([]*models.OrganizationDetails, len(o))
@@ -36,9 +38,8 @@ func (o Organizations) ToDetails() []*models.OrganizationDetails {
return orgs return orgs
} }
// Organization contains organization information.
type Organization struct { type Organization struct {
client *Client
HREF string `json:"href"` HREF string `json:"href"`
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -47,13 +48,20 @@ type Organization struct {
Projects Projects `json:"projects"` Projects Projects `json:"projects"`
} }
// ToDetails converts the object to a generic orgnization details.
func (o *Organization) ToDetails() *models.OrganizationDetails { func (o *Organization) ToDetails() *models.OrganizationDetails {
if o == nil || o.ID == "" { var id string
if o != nil {
id = idOrLinkID(o.ID, o.HREF)
}
if id == "" {
return nil return nil
} }
details := &models.OrganizationDetails{ details := &models.OrganizationDetails{
ID: o.ID, ID: id,
Name: o.Name, Name: o.Name,
Projects: o.Projects.ToDetails(), Projects: o.Projects.ToDetails(),
} }
@@ -63,10 +71,11 @@ func (o *Organization) ToDetails() *models.OrganizationDetails {
return details return details
} }
// getOrganizationWithMemberships fetches an organization from the equinix metal api with membership user information.
func (c *Client) getOrganizationWithMemberships(ctx context.Context, id string) (*Organization, error) { func (c *Client) getOrganizationWithMemberships(ctx context.Context, id string) (*Organization, error) {
var org Organization var org Organization
_, err := c.DoRequest(ctx, http.MethodGet, organizationsPath+"/"+id+"?include=memberships.user,projects.memberships.user", nil, &org) _, err := c.DoRequest(ctx, http.MethodGet, organizationsPath+"/"+id+"?include=memberships.user", nil, &org) // nolint:bodyclose // body is closed by Do json decode.
if err != nil { if err != nil {
return nil, fmt.Errorf("error loading organization: %w", err) return nil, fmt.Errorf("error loading organization: %w", err)
} }
@@ -74,6 +83,7 @@ func (c *Client) getOrganizationWithMemberships(ctx context.Context, id string)
return &org, nil return &org, nil
} }
// GetOrganizationDetails fetches the organization id provided with its memberships.
func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) { func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) {
org, err := c.getOrganizationWithMemberships(ctx, id.String()[gidx.PrefixPartLength+1:]) org, err := c.getOrganizationWithMemberships(ctx, id.String()[gidx.PrefixPartLength+1:])
if err != nil { if err != nil {

View File

@@ -14,8 +14,10 @@ const (
projectsPath = "/projects" projectsPath = "/projects"
) )
// Projects contains a list of projects.
type Projects []*Project type Projects []*Project
// ToDetails converts the objects to generic project details.
func (p Projects) ToDetails() []*models.ProjectDetails { func (p Projects) ToDetails() []*models.ProjectDetails {
projects := make([]*models.ProjectDetails, len(p)) projects := make([]*models.ProjectDetails, len(p))
@@ -36,9 +38,8 @@ func (p Projects) ToDetails() []*models.ProjectDetails {
return projects return projects
} }
// Project contains project information.
type Project struct { type Project struct {
client *Client
HREF string `json:"href"` HREF string `json:"href"`
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -47,13 +48,20 @@ type Project struct {
Organization *Organization `json:"organization"` Organization *Organization `json:"organization"`
} }
// ToDetails converts the project to generic project details.
func (p *Project) ToDetails() *models.ProjectDetails { func (p *Project) ToDetails() *models.ProjectDetails {
if p == nil || p.ID == "" { var id string
if p != nil {
id = idOrLinkID(p.ID, p.HREF)
}
if id == "" {
return nil return nil
} }
details := &models.ProjectDetails{ details := &models.ProjectDetails{
ID: p.ID, ID: id,
Name: p.Name, Name: p.Name,
Organization: p.Organization.ToDetails(), Organization: p.Organization.ToDetails(),
} }
@@ -63,19 +71,21 @@ func (p *Project) ToDetails() *models.ProjectDetails {
return details return details
} }
func (c *Client) getProject(ctx context.Context, id string) (*Project, error) { // getProjectWithMemberships fetches the provided project with membership information.
func (c *Client) getProjectWithMemberships(ctx context.Context, id string) (*Project, error) {
var project Project var project Project
_, err := c.DoRequest(ctx, http.MethodGet, c.baseURL.JoinPath(projectsPath, id).String(), nil, &project) _, err := c.DoRequest(ctx, http.MethodGet, projectsPath+"/"+id+"?include=memberships.user", nil, &project) // nolint:bodyclose // body is closed by Do json decode.
if err != nil { if err != nil {
return nil, fmt.Errorf("error loading project: %w", err) return nil, fmt.Errorf("error loading organization: %w", err)
} }
return &project, nil return &project, nil
} }
// GetProjectDetails fetchs the provided project id with membership information.
func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) { func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) {
project, err := c.getProject(ctx, id.String()[gidx.PrefixPartLength+1:]) project, err := c.getProjectWithMemberships(ctx, id.String()[gidx.PrefixPartLength+1:])
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -14,8 +14,10 @@ const (
usersPath = "/users" usersPath = "/users"
) )
// Users contains a list of users.
type Users []*User type Users []*User
// ToDetails converts the objects to generic user details.
func (u Users) ToDetails() []*models.UserDetails { func (u Users) ToDetails() []*models.UserDetails {
users := make([]*models.UserDetails, len(u)) users := make([]*models.UserDetails, len(u))
@@ -36,9 +38,8 @@ func (u Users) ToDetails() []*models.UserDetails {
return users return users
} }
// User contains user information.
type User struct { type User struct {
client *Client
HREF string `json:"href"` HREF string `json:"href"`
ID string `json:"id"` ID string `json:"id"`
FullName string `json:"full_name"` FullName string `json:"full_name"`
@@ -46,30 +47,39 @@ type User struct {
Projects Projects `json:"projects"` Projects Projects `json:"projects"`
} }
// ToDetails converts the user to generic user details.
func (u *User) ToDetails() *models.UserDetails { func (u *User) ToDetails() *models.UserDetails {
if u.ID == "" { var id string
if u != nil {
id = idOrLinkID(u.ID, u.HREF)
}
if id == "" {
return nil return nil
} }
return &models.UserDetails{ return &models.UserDetails{
ID: u.ID, ID: id,
FullName: u.FullName, FullName: u.FullName,
Organizations: nil, Organizations: nil,
Projects: u.Projects.ToDetails(), Projects: u.Projects.ToDetails(),
} }
} }
// getUser fetches the provided user.
func (c *Client) getUser(ctx context.Context, id string) (*User, error) { func (c *Client) getUser(ctx context.Context, id string) (*User, error) {
var user User var user User
_, err := c.DoRequest(ctx, http.MethodGet, c.baseURL.JoinPath(usersPath, id).String(), nil, &user) _, err := c.DoRequest(ctx, http.MethodGet, usersPath+"/"+id, nil, &user) // nolint:bodyclose // body is closed by Do json decode.
if err != nil { if err != nil {
return nil, fmt.Errorf("error loading user: %w", err) return nil, fmt.Errorf("error loading organization: %w", err)
} }
return &user, nil return &user, nil
} }
// GetUserDetails fetches the provided user id.
func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) { func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) {
user, err := c.getUser(ctx, id.String()[gidx.PrefixPartLength+1:]) user, err := c.getUser(ctx, id.String()[gidx.PrefixPartLength+1:])
if err != nil { if err != nil {
@@ -78,3 +88,13 @@ func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*model
return user.ToDetails(), nil return user.ToDetails(), nil
} }
// GetUserOrganizationRole returns collaborator for all organizations.
func (c *Client) GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) {
return "collaborator", nil
}
// GetUserProjectRole returns collaborator for all projects.
func (c *Client) GetUserProjectRole(ctx context.Context, userID, projectID gidx.PrefixedID) (string, error) {
return "collaborator", nil
}

View File

@@ -5,22 +5,23 @@ import (
"net/url" "net/url"
"time" "time"
provider "go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
"go.uber.org/zap" "go.uber.org/zap"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
) )
const ( const (
defaultHTTPTimeout = 5 * time.Second defaultHTTPTimeout = 5 * time.Second
) )
var ( // DefaultHTTPClient is the default http client used if no client is provided.
DefaultHTTPClient = &http.Client{ var DefaultHTTPClient = &http.Client{
Timeout: defaultHTTPTimeout, Timeout: defaultHTTPTimeout,
} }
)
var _ provider.Provider = &Client{} var _ providers.Provider = &Client{}
// Client is the client to interact with the equinix metal graphql service.
type Client struct { type Client struct {
logger *zap.SugaredLogger logger *zap.SugaredLogger
httpClient *http.Client httpClient *http.Client

View File

@@ -1,12 +1,24 @@
package emgql package emgql
import (
"github.com/spf13/pflag"
"github.com/spf13/viper"
"go.infratographer.com/x/viperx"
)
// Config provides configuration for connecting to the Equinix Metal API provider. // Config provides configuration for connecting to the Equinix Metal API provider.
type Config struct { type Config struct {
// BaseURL is the baseurl to use when connecting to the Equinix Metal API Provider. // BaseURL is the baseurl to use when connecting to the Equinix Metal API Provider.
BaseURL string BaseURL string
} }
// MustViperFlags registers command flags along with the viper bindings.
func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) {
flags.String("emgql-base-url", "", "Equinix Metal GraphQL Base URL")
viperx.MustBindFlag(v, "equinixmetal.emgql.baseurl", flags.Lookup("emgql-base-url"))
}
// Populated checks if any field has been populated.
func (c Config) Populated() bool { func (c Config) Populated() bool {
return c.BaseURL != "" return c.BaseURL != ""
} }

View File

@@ -0,0 +1,2 @@
// Package emgql implements a metal provider which fetches details from the Equinix Metal GraphQL.
package emgql

View File

@@ -3,10 +3,12 @@ package emgql
import ( import (
"context" "context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.infratographer.com/x/gidx" "go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
) )
// GetOrganizationDetails fetches the organization id provided with its memberships.
func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) { func (c *Client) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) {
return nil, nil return nil, nil
} }

View File

@@ -3,10 +3,12 @@ package emgql
import ( import (
"context" "context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.infratographer.com/x/gidx" "go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
) )
// GetProjectDetails fetchs the provided project id with membership information.
func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) { func (c *Client) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) {
return nil, nil return nil, nil
} }

View File

@@ -3,10 +3,22 @@ package emgql
import ( import (
"context" "context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.infratographer.com/x/gidx" "go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
) )
// GetUserDetails fetches the provided user id.
func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) { func (c *Client) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) {
return nil, nil return nil, nil
} }
// GetUserOrganizationRole returns collaborator for all organizations.
func (c *Client) GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) {
return "collaborator", nil
}
// GetUserProjectRole returns collaborator for all projects.
func (c *Client) GetUserProjectRole(ctx context.Context, userID, projID gidx.PrefixedID) (string, error) {
return "collaborator", nil
}

View File

@@ -0,0 +1,52 @@
package providers
import (
"context"
"github.com/stretchr/testify/mock"
"go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
)
var _ Provider = &MockProvider{}
// MockProvider implements Provider used for testing.
type MockProvider struct {
mock.Mock
}
// GetOrganizationDetails implements Provider used for testing.
func (p *MockProvider) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) {
args := p.Called(id)
return args.Get(0).(*models.OrganizationDetails), args.Error(1)
}
// GetProjectDetails implements Provider used for testing.
func (p *MockProvider) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) {
args := p.Called(id)
return args.Get(0).(*models.ProjectDetails), args.Error(1)
}
// GetUserDetails implements Provider used for testing.
func (p *MockProvider) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) {
args := p.Called(id)
return args.Get(0).(*models.UserDetails), args.Error(1)
}
// GetUserOrganizationRole implements Provider used for testing.
func (p *MockProvider) GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error) {
args := p.Called(userID, orgID)
return args.String(0), args.Error(1)
}
// GetUserProjectRole implements Provider used for testing.
func (p *MockProvider) GetUserProjectRole(ctx context.Context, userID, projID gidx.PrefixedID) (string, error) {
args := p.Called(userID, projID)
return args.String(0), args.Error(1)
}

View File

@@ -1,14 +1,19 @@
package provider // Package providers defines the provider interface for fetching metal resources.
package providers
import ( import (
"context" "context"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.infratographer.com/x/gidx" "go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
) )
// Provider defines the provider implementation.
type Provider interface { type Provider interface {
GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error) GetOrganizationDetails(ctx context.Context, id gidx.PrefixedID) (*models.OrganizationDetails, error)
GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error) GetProjectDetails(ctx context.Context, id gidx.PrefixedID) (*models.ProjectDetails, error)
GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error) GetUserDetails(ctx context.Context, id gidx.PrefixedID) (*models.UserDetails, error)
GetUserOrganizationRole(ctx context.Context, userID, orgID gidx.PrefixedID) (string, error)
GetUserProjectRole(ctx context.Context, userID, projID gidx.PrefixedID) (string, error)
} }

View File

@@ -8,22 +8,26 @@ import (
"go.infratographer.com/x/gidx" "go.infratographer.com/x/gidx"
) )
// RoleAssign is the role assignment request body.
type RoleAssign struct { type RoleAssign struct {
SubjectID string `json:"subject_id"` SubjectID string `json:"subject_id"`
} }
// RoleAssignResponse is the response from a role assignment.
type RoleAssignResponse struct { type RoleAssignResponse struct {
Success bool `json:"success"` Success bool `json:"success"`
} }
// roleAssignmentData is the response from listing a role assignment
type roleAssignmentData struct { type roleAssignmentData struct {
Data []struct { Data []struct {
SubjectID string `json:"subject_id"` SubjectID string `json:"subject_id"`
} `json:"data"` } `json:"data"`
} }
func (c *Client) AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error { // AssignRole assigns the provided member ID to the given role ID.
path := fmt.Sprintf("/api/v1/roles/%s/assignments", roleID.String()) func (c *client) AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error {
path := fmt.Sprintf(permsPathRoleAssignmentsFormat, roleID.String())
body, err := encodeJSON(RoleAssign{ body, err := encodeJSON(RoleAssign{
SubjectID: memberID.String(), SubjectID: memberID.String(),
@@ -34,7 +38,7 @@ func (c *Client) AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberI
var response RoleAssignResponse var response RoleAssignResponse
if _, err = c.DoRequest(ctx, http.MethodPost, path, body, &response); err != nil { if _, err = c.DoRequest(ctx, http.MethodPost, path, body, &response); err != nil { // nolint:bodyclose // body is closed by Do json decode.
return err return err
} }
@@ -45,12 +49,37 @@ func (c *Client) AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberI
return nil return nil
} }
func (c *Client) ListRoleAssignments(ctx context.Context, roleID gidx.PrefixedID) ([]gidx.PrefixedID, error) { // UnassignRole removes the provided member ID from the given role ID.
path := fmt.Sprintf("/api/v1/roles/%s/assignments", roleID.String()) func (c *client) UnassignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error {
path := fmt.Sprintf(permsPathRoleAssignmentsFormat, roleID.String())
body, err := encodeJSON(RoleAssign{
SubjectID: memberID.String(),
})
if err != nil {
return err
}
var response RoleAssignResponse
if _, err = c.DoRequest(ctx, http.MethodDelete, path, body, &response); err != nil { // nolint:bodyclose // body is closed by Do json decode.
return err
}
if !response.Success {
return ErrUnassignmentFailed
}
return nil
}
// ListRoleAssignments lists all assignments for the given role.
func (c *client) ListRoleAssignments(ctx context.Context, roleID gidx.PrefixedID) ([]gidx.PrefixedID, error) {
path := fmt.Sprintf(permsPathRoleAssignmentsFormat, roleID.String())
var response roleAssignmentData var response roleAssignmentData
if _, err := c.DoRequest(ctx, http.MethodGet, path, nil, &response); err != nil { if _, err := c.DoRequest(ctx, http.MethodGet, path, nil, &response); err != nil { // nolint:bodyclose // body is closed by Do json decode.
return nil, err return nil, err
} }
@@ -68,7 +97,8 @@ func (c *Client) ListRoleAssignments(ctx context.Context, roleID gidx.PrefixedID
return assignments, nil return assignments, nil
} }
func (c *Client) RoleHasAssignment(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) (bool, error) { // RoleHasAssignment gets the assignments for the given role and check for the provided member id.
func (c *client) RoleHasAssignment(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) (bool, error) {
assignments, err := c.ListRoleAssignments(ctx, roleID) assignments, err := c.ListRoleAssignments(ctx, roleID)
if err != nil { if err != nil {
return false, err return false, err

View File

@@ -12,16 +12,46 @@ import (
"time" "time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.infratographer.com/x/gidx"
"go.uber.org/zap" "go.uber.org/zap"
) )
const defaultPermissionsURL = "https://permissions-api.hollow-a.sv15.metalkube.net" const (
defaultHTTPClientTimeout = 5 * time.Second
var defaultHTTPClient = &http.Client{ defaultPermissionsURL = "https://permissions-api.hollow-a.sv15.metalkube.net"
Timeout: 5 * time.Second, permsPathAllow = "/api/v1/allow"
permsPathResourceRelationshipsFormat = "/api/v1/resources/%s/relationships"
permsPathResourceRelationshipsFrom = "/api/v1/relationships/from/"
permsPathResourceRelationshipsTo = "/api/v1/relationships/to/"
permsPathResourceRolesFormat = "/api/v1/resources/%s/roles"
permsPathRoleAssignmentsFormat = "/api/v1/roles/%s/assignments"
)
// DefaultHTTPClient is the default HTTP client for the Permissions Client.
var DefaultHTTPClient = &http.Client{
Timeout: defaultHTTPClientTimeout,
} }
type Client struct { // Client defines the Permissions API client interface.
type Client interface {
AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error
CreateRole(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (gidx.PrefixedID, error)
DeleteResourceRelationship(ctx context.Context, resourceID gidx.PrefixedID, relation string, relatedResourceID gidx.PrefixedID) error
DeleteRole(ctx context.Context, roleID gidx.PrefixedID) error
FindResourceRoleByActions(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (ResourceRole, error)
ListResourceRelationshipsFrom(ctx context.Context, resourceID gidx.PrefixedID) ([]ResourceRelationship, error)
ListResourceRelationshipsTo(ctx context.Context, resourceID gidx.PrefixedID) ([]ResourceRelationship, error)
ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) (ResourceRoles, error)
ListRoleAssignments(ctx context.Context, roleID gidx.PrefixedID) ([]gidx.PrefixedID, error)
RoleHasAssignment(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) (bool, error)
UnassignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error
}
// client is the permissions client.
type client struct {
logger *zap.SugaredLogger logger *zap.SugaredLogger
httpClient *http.Client httpClient *http.Client
@@ -32,7 +62,9 @@ type Client struct {
allowURL *url.URL allowURL *url.URL
} }
func (c *Client) Do(req *http.Request, out any) (*http.Response, error) { // Do executes the provided request.
// If the out value is provided, the response will attempt to be json decoded.
func (c *client) Do(req *http.Request, out any) (*http.Response, error) {
if c.token != "" { if c.token != "" {
req.Header.Set(echo.HeaderAuthorization, "Bearer "+c.token) req.Header.Set(echo.HeaderAuthorization, "Bearer "+c.token)
} }
@@ -55,7 +87,8 @@ func (c *Client) Do(req *http.Request, out any) (*http.Response, error) {
return resp, nil return resp, nil
} }
func (c *Client) DoRequest(ctx context.Context, method, path string, body io.Reader, out any) (*http.Response, error) { // DoRequest creates a new request from the provided parameters and executes the request.
func (c *client) DoRequest(ctx context.Context, method, path string, body io.Reader, out any) (*http.Response, error) {
path = strings.TrimPrefix(path, c.baseURL.Path) path = strings.TrimPrefix(path, c.baseURL.Path)
pathURL, err := url.Parse(path) pathURL, err := url.Parse(path)
@@ -100,10 +133,11 @@ func encodeJSON(v any) (*bytes.Buffer, error) {
return &buff, nil return &buff, nil
} }
func NewClient(token string, options ...Option) (*Client, error) { // NewClient creats a new permissions client.
client := &Client{ func NewClient(token string, options ...Option) (Client, error) {
client := &client{
logger: zap.NewNop().Sugar(), logger: zap.NewNop().Sugar(),
httpClient: defaultHTTPClient, httpClient: DefaultHTTPClient,
token: token, token: token,
} }

View File

@@ -18,6 +18,7 @@ type Config struct {
BearerToken string BearerToken string
} }
// MustViperFlags registers command flags along with the viper bindings.
func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) { func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) {
flags.String("permissions-baseurl", "", "permissions base url") flags.String("permissions-baseurl", "", "permissions base url")
viperx.MustBindFlag(v, "permissions.baseurl", flags.Lookup("permissions-baseurl")) viperx.MustBindFlag(v, "permissions.baseurl", flags.Lookup("permissions-baseurl"))
@@ -28,7 +29,7 @@ func MustViperFlags(v *viper.Viper, flags *pflag.FlagSet) {
// WithConfig applies all configurations defined in the config. // WithConfig applies all configurations defined in the config.
func WithConfig(config Config) Option { func WithConfig(config Config) Option {
return func(c *Client) error { return func(c *client) error {
var options []Option var options []Option
if config.BaseURL != "" { if config.BaseURL != "" {
@@ -51,7 +52,7 @@ func WithConfig(config Config) Option {
// WithBaseURL updates the baseurl used by the client. // WithBaseURL updates the baseurl used by the client.
func WithBaseURL(baseURL string) Option { func WithBaseURL(baseURL string) Option {
return func(c *Client) error { return func(c *client) error {
u, err := url.Parse(baseURL) u, err := url.Parse(baseURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse emapi base url %s: %w", baseURL, err) return fmt.Errorf("failed to parse emapi base url %s: %w", baseURL, err)
@@ -65,7 +66,7 @@ func WithBaseURL(baseURL string) Option {
// WithBearerToken sets the bearer token to authenticate the request with. // WithBearerToken sets the bearer token to authenticate the request with.
func WithBearerToken(token string) Option { func WithBearerToken(token string) Option {
return func(c *Client) error { return func(c *client) error {
c.token = token c.token = token
return nil return nil

View File

@@ -0,0 +1,2 @@
// Package permissions implements a Permissions API client for fetching and manipulating relationships and role assignments.
package permissions

View File

@@ -3,6 +3,18 @@ package permissions
import "errors" import "errors"
var ( var (
// ErrRoleNotFound is returned when no role is found for a given list of actions.
ErrRoleNotFound = errors.New("role not found") ErrRoleNotFound = errors.New("role not found")
// ErrAssignmentFailed is returned when a user assignment to a role fails.
ErrAssignmentFailed = errors.New("assignment failed") ErrAssignmentFailed = errors.New("assignment failed")
// ErrUnassignmentFailed is returned when a user assignment is removed from a role fails.
ErrUnassignmentFailed = errors.New("unassignment failed")
// ErrUnexpectedRoleDeleteFailed is returned when an unknown error is returned when deleting a role.
ErrUnexpectedRoleDeleteFailed = errors.New("unknown role delete error")
// ErrUnexpectedRelationshipDeleteFailed is returned when an unknown error is returned when deleting a relationship.
ErrUnexpectedRelationshipDeleteFailed = errors.New("unknown relationship delete error")
) )

View File

@@ -0,0 +1,90 @@
package permissions
import (
"context"
"github.com/stretchr/testify/mock"
"go.infratographer.com/x/gidx"
)
// MockClient implements Client for testing.
type MockClient struct {
mock.Mock
}
// AssignRole implements Client for testing.
func (c *MockClient) AssignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error {
args := c.Called(roleID, memberID)
return args.Error(0)
}
// CreateRole implements Client for testing.
func (c *MockClient) CreateRole(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (gidx.PrefixedID, error) {
args := c.Called(resourceID, actions)
return args.Get(0).(gidx.PrefixedID), args.Error(1)
}
// DeleteResourceRelationship implements Client for testing.
func (c *MockClient) DeleteResourceRelationship(ctx context.Context, resourceID gidx.PrefixedID, relation string, relatedResourceID gidx.PrefixedID) error {
args := c.Called(resourceID, relation, relatedResourceID)
return args.Error(0)
}
// DeleteRole implements Client for testing.
func (c *MockClient) DeleteRole(ctx context.Context, roleID gidx.PrefixedID) error {
args := c.Called(roleID)
return args.Error(0)
}
// FindResourceRoleByActions implements Client for testing.
func (c *MockClient) FindResourceRoleByActions(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (ResourceRole, error) {
args := c.Called(resourceID, actions)
return args.Get(0).(ResourceRole), args.Error(1)
}
// ListResourceRelationshipsFrom implements Client for testing.
func (c *MockClient) ListResourceRelationshipsFrom(ctx context.Context, resourceID gidx.PrefixedID) ([]ResourceRelationship, error) {
args := c.Called(resourceID)
return args.Get(0).([]ResourceRelationship), args.Error(1)
}
// ListResourceRelationshipsTo implements Client for testing.
func (c *MockClient) ListResourceRelationshipsTo(ctx context.Context, resourceID gidx.PrefixedID) ([]ResourceRelationship, error) {
args := c.Called(resourceID)
return args.Get(0).([]ResourceRelationship), args.Error(1)
}
// ListResourceRoles implements Client for testing.
func (c *MockClient) ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) (ResourceRoles, error) {
args := c.Called(resourceID)
return args.Get(0).(ResourceRoles), args.Error(1)
}
// ListRoleAssignments implements Client for testing.
func (c *MockClient) ListRoleAssignments(ctx context.Context, roleID gidx.PrefixedID) ([]gidx.PrefixedID, error) {
args := c.Called(roleID)
return args.Get(0).([]gidx.PrefixedID), args.Error(1)
}
// RoleHasAssignment implements Client for testing.
func (c *MockClient) RoleHasAssignment(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) (bool, error) {
args := c.Called(roleID, memberID)
return args.Bool(0), args.Error(1)
}
// UnassignRole implements Client for testing.
func (c *MockClient) UnassignRole(ctx context.Context, roleID gidx.PrefixedID, memberID gidx.PrefixedID) error {
args := c.Called(roleID, memberID)
return args.Error(0)
}

View File

@@ -1,15 +1,28 @@
package permissions package permissions
import ( import (
"net/http"
"go.uber.org/zap" "go.uber.org/zap"
) )
type Option func(*Client) error // Option is a client configuration option definition.
type Option func(*client) error
// WithLogger sets the logger for the client.
func WithLogger(logger *zap.SugaredLogger) Option { func WithLogger(logger *zap.SugaredLogger) Option {
return func(c *Client) error { return func(c *client) error {
c.logger = logger c.logger = logger
return nil return nil
} }
} }
// WithHTTPClient sets the http client to be used by the client.
func WithHTTPClient(httpClient *http.Client) Option {
return func(c *client) error {
c.httpClient = httpClient
return nil
}
}

View File

@@ -0,0 +1,112 @@
package permissions
import (
"context"
"fmt"
"net/http"
"go.infratographer.com/x/gidx"
)
type resourceRelationship struct {
ResourceID string `json:"resource_id"`
Relation string `json:"relation"`
SubjectID string `json:"subject_id"`
}
// ResourceRelationship defines the resource to subject relationship.
type ResourceRelationship struct {
ResourceID gidx.PrefixedID
Relation string
SubjectID gidx.PrefixedID
}
// ResourceRelationshipRequest defines the request to relate to a subject.
type ResourceRelationshipRequest struct {
Relation string `json:"relation"`
SubjectID string `json:"subject_id"`
}
// ResourceRelationshipDeleteResponse defines the response for a delete of a relationship.
type ResourceRelationshipDeleteResponse struct {
Success bool `json:"success"`
}
// DeleteResourceRelationship deletes the provided resources relationship to the given subject id.
func (c *client) DeleteResourceRelationship(ctx context.Context, resourceID gidx.PrefixedID, relation string, relatedResourceID gidx.PrefixedID) error {
path := fmt.Sprintf(permsPathResourceRelationshipsFormat, resourceID.String())
body, err := encodeJSON(ResourceRelationshipRequest{
Relation: relation,
SubjectID: relatedResourceID.String(),
})
if err != nil {
return err
}
var response ResourceRelationshipDeleteResponse
if _, err := c.DoRequest(ctx, http.MethodDelete, path, body, &response); err != nil { // nolint:bodyclose // closed by Do on json decode.
return err
}
if !response.Success {
return ErrUnexpectedRelationshipDeleteFailed
}
return nil
}
// ListResourceRelationshipsFrom returns resources related to the given id.
func (c *client) ListResourceRelationshipsFrom(ctx context.Context, resourceID gidx.PrefixedID) ([]ResourceRelationship, error) {
var response struct {
Data []resourceRelationship `json:"data"`
}
if _, err := c.DoRequest(ctx, http.MethodGet, permsPathResourceRelationshipsFrom+resourceID.String(), nil, &response); err != nil { // nolint:bodyclose // closed by Do on json decode.
return nil, err
}
data := make([]ResourceRelationship, len(response.Data))
for i, entry := range response.Data {
subID, err := gidx.Parse(entry.SubjectID)
if err != nil {
return nil, err
}
data[i] = ResourceRelationship{
Relation: entry.Relation,
SubjectID: subID,
}
}
return data, nil
}
// ListResourceRelationshipsTo returns resources related to the given id.
func (c *client) ListResourceRelationshipsTo(ctx context.Context, resourceID gidx.PrefixedID) ([]ResourceRelationship, error) {
var response struct {
Data []resourceRelationship `json:"data"`
}
if _, err := c.DoRequest(ctx, http.MethodGet, permsPathResourceRelationshipsTo+resourceID.String(), nil, &response); err != nil { // nolint:bodyclose // closed by Do on json decode.
return nil, err
}
data := make([]ResourceRelationship, len(response.Data))
for i, entry := range response.Data {
resID, err := gidx.Parse(entry.ResourceID)
if err != nil {
return nil, err
}
data[i] = ResourceRelationship{
ResourceID: resID,
Relation: entry.Relation,
}
}
return data, nil
}

View File

@@ -9,23 +9,33 @@ import (
"golang.org/x/exp/slices" "golang.org/x/exp/slices"
) )
// ResourceRoleCreate is the role create request.
type ResourceRoleCreate struct { type ResourceRoleCreate struct {
Actions []string `json:"actions"` Actions []string `json:"actions"`
} }
// ResourceRoleCreateResponse is the role creation response.
type ResourceRoleCreateResponse struct { type ResourceRoleCreateResponse struct {
ID string `json:"id"` ID string `json:"id"`
} }
// ResourceRoleDeleteResponse is the role deletion response.
type ResourceRoleDeleteResponse struct {
Success bool `json:"success"`
}
// ResourceRoles is a listg of resource roles.
type ResourceRoles []ResourceRole type ResourceRoles []ResourceRole
// ResourceRole contains the role id and its actions.
type ResourceRole struct { type ResourceRole struct {
ID gidx.PrefixedID `json:"id"` ID gidx.PrefixedID `json:"id"`
Actions []string `json:"actions"` Actions []string `json:"actions"`
} }
func (c *Client) CreateRole(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (gidx.PrefixedID, error) { // CreateRole creates a role on the given resource id with the provided actions.
path := fmt.Sprintf("/api/v1/resources/%s/roles", resourceID.String()) func (c *client) CreateRole(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (gidx.PrefixedID, error) {
path := fmt.Sprintf(permsPathResourceRolesFormat, resourceID.String())
body, err := encodeJSON(ResourceRoleCreate{ body, err := encodeJSON(ResourceRoleCreate{
Actions: actions, Actions: actions,
@@ -36,7 +46,7 @@ func (c *Client) CreateRole(ctx context.Context, resourceID gidx.PrefixedID, act
var response ResourceRoleCreateResponse var response ResourceRoleCreateResponse
if _, err = c.DoRequest(ctx, http.MethodPost, path, body, &response); err != nil { if _, err = c.DoRequest(ctx, http.MethodPost, path, body, &response); err != nil { // nolint:bodyclose // closed by Do on json decode.
return gidx.NullPrefixedID, err return gidx.NullPrefixedID, err
} }
@@ -48,19 +58,40 @@ func (c *Client) CreateRole(ctx context.Context, resourceID gidx.PrefixedID, act
return roleID, nil return roleID, nil
} }
func (c *Client) ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) (ResourceRoles, error) { // DeleteRole deletes the provided role.
path := fmt.Sprintf("/api/v1/resources/%s/roles", resourceID.String()) func (c *client) DeleteRole(ctx context.Context, roleID gidx.PrefixedID) error {
path := fmt.Sprintf("/api/v1/roles/%s", roleID.String())
var response ResourceRoles var response ResourceRoleDeleteResponse
if _, err := c.DoRequest(ctx, http.MethodGet, path, nil, &response); err != nil { if _, err := c.DoRequest(ctx, http.MethodDelete, path, nil, &response); err != nil { // nolint:bodyclose // closed by Do on json decode.
return err
}
if !response.Success {
return ErrUnexpectedRoleDeleteFailed
}
return nil
}
// ListResourceRoles fetches all roles assigned to the provided resource.
func (c *client) ListResourceRoles(ctx context.Context, resourceID gidx.PrefixedID) (ResourceRoles, error) {
path := fmt.Sprintf(permsPathResourceRolesFormat, resourceID.String())
var response struct {
Data ResourceRoles `json:"data"`
}
if _, err := c.DoRequest(ctx, http.MethodGet, path, nil, &response); err != nil { // nolint:bodyclose // closed by Do on json decode.
return nil, err return nil, err
} }
return response, nil return response.Data, nil
} }
func (c *Client) FindResourceRoleByActions(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (ResourceRole, error) { // FindResourceRoleByActions fetches roles assigned to the provided resource and finds the first role where the actions match the provided actions.
func (c *client) FindResourceRoleByActions(ctx context.Context, resourceID gidx.PrefixedID, actions []string) (ResourceRole, error) {
roles, err := c.ListResourceRoles(ctx, resourceID) roles, err := c.ListResourceRoles(ctx, resourceID)
if err != nil { if err != nil {
return ResourceRole{}, err return ResourceRole{}, err

132
internal/pubsub/nats.go Normal file
View File

@@ -0,0 +1,132 @@
package pubsub
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"strings"
"github.com/nats-io/nats.go"
"github.com/pkg/errors"
"go.infratographer.com/x/events"
"go.uber.org/zap"
)
const subscriptionBufferSize = 10
type changeEvent struct {
*nats.Msg
events.ChangeMessage
Error error
}
type subscriber struct {
logger *zap.SugaredLogger
nats *nats.Conn
jetstream nats.JetStreamContext
topicPrefix string
queueGroup string
subscriptionOptions []nats.SubOpt
subscriptions []*nats.Subscription
context context.Context
cancelCtx func()
}
func (s *subscriber) durableName(topic string) string {
hash := md5.Sum([]byte(topic))
return s.queueGroup + hex.EncodeToString(hash[:])
}
// SubscribeChanges subscribes to a topic, returning a channel which change events will be sent to.
func (s *subscriber) SubscribeChanges(ctx context.Context, topic string) (<-chan *changeEvent, error) {
subject := strings.Join([]string{s.topicPrefix, "changes", topic}, ".")
opts := s.subscriptionOptions
opts = append(opts, nats.Durable(s.durableName(subject)), nats.AckExplicit(), nats.ManualAck())
msgCh := make(chan *changeEvent, subscriptionBufferSize)
sub, err := s.jetstream.QueueSubscribe(subject, s.queueGroup, func(msg *nats.Msg) {
event := &changeEvent{
Msg: msg,
}
if err := json.NewDecoder(bytes.NewBuffer(msg.Data)).Decode(&event.ChangeMessage); err != nil {
event.Error = err
}
msgCh <- event
}, opts...)
if err != nil {
return nil, err
}
s.subscriptions = append(s.subscriptions, sub)
go func(subscriber *nats.Subscription) {
select {
case <-ctx.Done():
case <-s.context.Done():
}
if err := sub.Unsubscribe(); err != nil {
s.logger.Errorw("unable to unsubscribe", "error", err, "subject", subject)
}
}(sub)
return msgCh, nil
}
// Close closes the underlying nats connection.
func (s *subscriber) Close() {
s.cancelCtx()
s.nats.Close()
}
func newSubscriber(ctx context.Context, config events.SubscriberConfig, logger *zap.SugaredLogger, subOptions ...nats.SubOpt) (*subscriber, error) {
options := []nats.Option{
nats.Timeout(config.Timeout),
}
switch {
case config.NATSConfig.CredsFile != "":
options = append(options, nats.UserCredentials(config.NATSConfig.CredsFile))
case config.NATSConfig.Token != "":
options = append(options, nats.Token(config.NATSConfig.Token))
}
conn, err := nats.Connect(config.URL, options...)
if err != nil {
return nil, errors.Wrap(err, "cannot connect to NATS")
}
js, err := conn.JetStream()
if err != nil {
conn.Close()
return nil, errors.Wrap(err, "cannot initialize JetStream")
}
ctx, cancel := context.WithCancel(ctx)
return &subscriber{
logger: logger,
nats: conn,
jetstream: js,
queueGroup: config.QueueGroup,
topicPrefix: config.Prefix,
subscriptionOptions: subOptions,
context: ctx,
cancelCtx: cancel,
}, nil
}

View File

@@ -3,27 +3,30 @@ package pubsub
import ( import (
"context" "context"
"sync" "sync"
"time"
nc "github.com/nats-io/nats.go" nc "github.com/nats-io/nats.go"
"go.infratographer.com/x/events" "go.infratographer.com/x/events"
"go.infratographer.com/x/gidx"
"go.opentelemetry.io/otel" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace" "go.opentelemetry.io/otel/trace"
"go.uber.org/zap" "go.uber.org/zap"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.equinixmetal.net/infra9-metal-bridge/internal/service" "go.equinixmetal.net/infra9-metal-bridge/internal/service"
"github.com/ThreeDotsLabs/watermill/message"
) )
var tracer = otel.Tracer("go.infratographer.com/permissions-api/internal/pubsub") const defaultNakDelay = 10 * time.Second
var tracer = otel.Tracer("go.equinixmetal.net/infra9-metal-bridge")
// Subscriber is the subscriber client // Subscriber is the subscriber client
type Subscriber struct { type Subscriber struct {
ctx context.Context ctx context.Context
changeChannels []<-chan *message.Message changeChannels []<-chan *changeEvent
logger *zap.SugaredLogger logger *zap.SugaredLogger
subscriber *events.Subscriber subscriber *subscriber
subOpts []nc.SubOpt subOpts []nc.SubOpt
svc service.Service svc service.Service
} }
@@ -57,7 +60,7 @@ func NewSubscriber(ctx context.Context, cfg events.SubscriberConfig, service ser
opt(s) opt(s)
} }
sub, err := events.NewSubscriber(cfg, s.subOpts...) sub, err := newSubscriber(ctx, cfg, s.logger, s.subOpts...)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -100,50 +103,60 @@ func (s Subscriber) Listen() error {
} }
// listen listens for messages on a channel and calls the registered message handler // listen listens for messages on a channel and calls the registered message handler
func (s Subscriber) listen(messages <-chan *message.Message, wg *sync.WaitGroup) { func (s Subscriber) listen(messages <-chan *changeEvent, wg *sync.WaitGroup) {
defer wg.Done() defer wg.Done()
for msg := range messages { for msg := range messages {
s.logger.Infow("processing event", "event.id", msg.UUID) mlogger := s.logger.With(
if err := s.processEvent(msg); err != nil { "nats.subject", msg.Subject,
s.logger.Warn("Failed to process msg: ", err) "event.subject.id", msg.SubjectID,
"event.type", msg.EventType,
)
s.logger.Infow("message nacked", "event.id", msg.UUID) mlogger.Infow("processing event")
msg.Nack()
if err := s.processEvent(msg); err != nil {
mlogger.Errorw("Failed to process msg: ", "error", err)
if err = msg.NakWithDelay(defaultNakDelay); err != nil {
mlogger.Errorw("error naking failed message", "error", err)
}
} else { } else {
s.logger.Infow("message acked", "event.id", msg.UUID) if err = msg.Ack(); err != nil {
msg.Ack() mlogger.Warnw("error acking message", "error", err)
}
} }
} }
} }
// Close closes the subscriber connection and unsubscribes from all subscriptions // Close closes the subscriber connection and unsubscribes from all subscriptions
func (s *Subscriber) Close() error { func (s *Subscriber) Close() {
return s.subscriber.Close() s.subscriber.Close()
} }
// processEvent event message handler // processEvent event message handler
func (s *Subscriber) processEvent(msg *message.Message) error { func (s *Subscriber) processEvent(msg *changeEvent) error {
changeMsg, err := events.UnmarshalChangeMessage(msg.Payload) mlogger := s.logger.With(
if err != nil { "nats.subject", msg.Subject,
s.logger.Errorw("failed to process data in msg", zap.Error(err)) "event.subject.id", msg.SubjectID,
"event.type", msg.EventType,
)
return err ctx, span := tracer.Start(context.Background(), "pubsub.receive", trace.WithAttributes(attribute.String("pubsub.subject", msg.SubjectID.String())))
}
ctx, span := tracer.Start(context.Background(), "pubsub.receive", trace.WithAttributes(attribute.String("pubsub.subject", changeMsg.SubjectID.String())))
defer span.End() defer span.End()
switch events.ChangeType(changeMsg.EventType) { var err error
switch events.ChangeType(msg.EventType) {
case events.CreateChangeType: case events.CreateChangeType:
err = s.handleTouchEvent(ctx, msg, changeMsg) err = s.handleTouchEvent(ctx, msg)
case events.UpdateChangeType: case events.UpdateChangeType:
err = s.handleTouchEvent(ctx, msg, changeMsg) err = s.handleTouchEvent(ctx, msg)
case events.DeleteChangeType: case events.DeleteChangeType:
err = s.handleDeleteEvent(ctx, msg, changeMsg) err = s.handleDeleteEvent(ctx, msg)
default: default:
s.logger.Warnw("ignoring msg, not a create, update or delete event", "event_type", changeMsg.EventType) mlogger.Warn("ignoring msg, not a create, update or delete event")
} }
if err != nil { if err != nil {
@@ -153,9 +166,15 @@ func (s *Subscriber) processEvent(msg *message.Message) error {
return nil return nil
} }
func (s *Subscriber) handleTouchEvent(ctx context.Context, msg *message.Message, changeMsg events.ChangeMessage) error { func (s *Subscriber) handleTouchEvent(ctx context.Context, msg *changeEvent) error {
if s.svc.IsOrganizationID(changeMsg.SubjectID) { mlogger := s.logger.With(
if err := s.svc.TouchOrganization(ctx, changeMsg.SubjectID); err != nil { "nats.subject", msg.Subject,
"event.subject.id", msg.SubjectID,
"event.type", msg.EventType,
)
if s.svc.IsOrganizationID(msg.SubjectID) {
if err := s.svc.TouchOrganization(ctx, msg.SubjectID); err != nil {
// TODO: only return errors on retryable errors // TODO: only return errors on retryable errors
return err return err
} }
@@ -163,8 +182,8 @@ func (s *Subscriber) handleTouchEvent(ctx context.Context, msg *message.Message,
return nil return nil
} }
if s.svc.IsProjectID(changeMsg.SubjectID) { if s.svc.IsProjectID(msg.SubjectID) {
if err := s.svc.TouchProject(ctx, changeMsg.SubjectID); err != nil { if err := s.svc.TouchProject(ctx, msg.SubjectID); err != nil {
// TODO: only return errors on retryable errors // TODO: only return errors on retryable errors
return err return err
} }
@@ -172,8 +191,17 @@ func (s *Subscriber) handleTouchEvent(ctx context.Context, msg *message.Message,
return nil return nil
} }
if s.svc.IsUser(changeMsg.SubjectID) { if s.svc.IsUser(msg.SubjectID) {
if err := s.svc.TouchUser(ctx, changeMsg.SubjectID); err != nil { userUUID := msg.SubjectID.String()[gidx.PrefixPartLength+1:]
subjID, err := models.GenerateSubjectID(models.IdentityPrefixUser, models.MetalUserIssuer, models.MetalUserIssuerIDPrefix+userUUID)
if err != nil {
mlogger.Errorw("failed to convert user id to identity id", "error", err)
return nil
}
if err := s.svc.AssignUser(ctx, subjID, msg.AdditionalSubjectIDs...); err != nil {
// TODO: only return errors on retryable errors // TODO: only return errors on retryable errors
return err return err
} }
@@ -181,14 +209,20 @@ func (s *Subscriber) handleTouchEvent(ctx context.Context, msg *message.Message,
return nil return nil
} }
s.logger.Warnw("unknown subject id", "subject.id", changeMsg.SubjectID) mlogger.Warnw("unknown subject id")
return nil return nil
} }
func (s *Subscriber) handleDeleteEvent(ctx context.Context, msg *message.Message, changeMsg events.ChangeMessage) error { func (s *Subscriber) handleDeleteEvent(ctx context.Context, msg *changeEvent) error {
if s.svc.IsOrganizationID(changeMsg.SubjectID) { mlogger := s.logger.With(
if err := s.svc.DeleteOrganization(ctx, changeMsg.SubjectID); err != nil { "nats.subject", msg.Subject,
"event.subject.id", msg.SubjectID,
"event.type", msg.EventType,
)
if s.svc.IsOrganizationID(msg.SubjectID) {
if err := s.svc.DeleteOrganization(ctx, msg.SubjectID); err != nil {
// TODO: only return errors on retryable errors // TODO: only return errors on retryable errors
return err return err
} }
@@ -196,8 +230,8 @@ func (s *Subscriber) handleDeleteEvent(ctx context.Context, msg *message.Message
return nil return nil
} }
if s.svc.IsProjectID(changeMsg.SubjectID) { if s.svc.IsProjectID(msg.SubjectID) {
if err := s.svc.DeleteProject(ctx, changeMsg.SubjectID); err != nil { if err := s.svc.DeleteProject(ctx, msg.SubjectID); err != nil {
// TODO: only return errors on retryable errors // TODO: only return errors on retryable errors
return err return err
} }
@@ -205,8 +239,17 @@ func (s *Subscriber) handleDeleteEvent(ctx context.Context, msg *message.Message
return nil return nil
} }
if s.svc.IsUser(changeMsg.SubjectID) { if s.svc.IsUser(msg.SubjectID) {
if err := s.svc.DeleteUser(ctx, changeMsg.SubjectID); err != nil { userUUID := msg.SubjectID.String()[gidx.PrefixPartLength+1:]
subjID, err := models.GenerateSubjectID(models.IdentityPrefixUser, models.MetalUserIssuer, models.MetalUserIssuerIDPrefix+userUUID)
if err != nil {
mlogger.Errorw("failed to convert user id to identity id", "error", err)
return nil
}
if err := s.svc.UnassignUser(ctx, subjID, msg.AdditionalSubjectIDs...); err != nil {
// TODO: only return errors on retryable errors // TODO: only return errors on retryable errors
return err return err
} }
@@ -214,7 +257,7 @@ func (s *Subscriber) handleDeleteEvent(ctx context.Context, msg *message.Message
return nil return nil
} }
s.logger.Warnw("unknown subject id", "subject.id", changeMsg.SubjectID) mlogger.Warnw("unknown subject id")
return nil return nil
} }

View File

@@ -1,22 +0,0 @@
package routes
import (
"errors"
"net/http"
"github.com/labstack/echo/v4"
)
var (
// ErrInvalidJWTPrivateKeyType is returned when the private key type is not of an expected value.
ErrInvalidJWTPrivateKeyType = errors.New("invalid JWT private key provided")
// ErrAuthTokenHeaderRequired is returned when a token check request is made, but the Authorization header is missing.
ErrAuthTokenHeaderRequired = echo.NewHTTPError(http.StatusBadRequest, "header Authorization missing or invalid")
// ErrInvalidSigningMethod is returned when defined jwt signing method is not recognized.
ErrInvalidSigningMethod = errors.New("unrecognized jwt signing method provided")
// ErrMissingIssuer is returned when the jwt issuer is not defined in the config.
ErrMissingIssuer = errors.New("jwt issuer required")
)

View File

@@ -1,27 +0,0 @@
package routes
import (
"go.equinixmetal.net/infra9-metal-bridge/internal/service"
"go.uber.org/zap"
)
// Option is a functional configuration option for the router.
type Option func(r *Router) error
// WithLogger sets the logger for the router.
func WithLogger(logger *zap.Logger) Option {
return func(r *Router) error {
r.logger = logger
return nil
}
}
// WithService sets the service handler.
func WithService(svc service.Service) Option {
return func(r *Router) error {
r.svc = svc
return nil
}
}

View File

@@ -1,32 +0,0 @@
// Package routes provides the routes for the application.
package routes
import (
"github.com/labstack/echo/v4"
"go.equinixmetal.net/infra9-metal-bridge/internal/service"
"go.uber.org/zap"
)
// Router is the router for the application.
type Router struct {
logger *zap.Logger
svc service.Service
}
// Routes registers the routes for the application.
func (r *Router) Routes(g *echo.Group) {}
// NewRouter creates a new router
func NewRouter(opts ...Option) (*Router, error) {
router := Router{
logger: zap.NewNop(),
}
for _, opt := range opts {
if err := opt(&router); err != nil {
return nil, err
}
}
return &router, nil
}

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"go.infratographer.com/x/gidx" "go.infratographer.com/x/gidx"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/exp/slices"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal" "go.equinixmetal.net/infra9-metal-bridge/internal/metal"
"go.equinixmetal.net/infra9-metal-bridge/internal/permissions" "go.equinixmetal.net/infra9-metal-bridge/internal/permissions"
@@ -21,7 +22,7 @@ func WithLogger(logger *zap.SugaredLogger) Option {
} }
// WithMetalClient sets the Equinix Metal client used by the service. // WithMetalClient sets the Equinix Metal client used by the service.
func WithMetalClient(client *metal.Client) Option { func WithMetalClient(client metal.Client) Option {
return func(s *service) error { return func(s *service) error {
s.metal = client s.metal = client
@@ -30,7 +31,7 @@ func WithMetalClient(client *metal.Client) Option {
} }
// WithPermissionsClient sets the permissions client used by the service. // WithPermissionsClient sets the permissions client used by the service.
func WithPermissionsClient(client *permissions.Client) Option { func WithPermissionsClient(client permissions.Client) Option {
return func(s *service) error { return func(s *service) error {
s.perms = client s.perms = client
@@ -39,7 +40,7 @@ func WithPermissionsClient(client *permissions.Client) Option {
} }
// WithPrefixMap sets the id prefix map relating id prefixes to type names. // WithPrefixMap sets the id prefix map relating id prefixes to type names.
func WithPrefixMap(idMap map[string]string) Option { func WithPrefixMap(idMap map[string]ObjectType) Option {
return func(s *service) error { return func(s *service) error {
s.idPrefixMap = idMap s.idPrefixMap = idMap
@@ -55,7 +56,7 @@ func WithRootTenant(sid string) Option {
return err return err
} }
s.rootResource = rootResource{id} s.rootResource = prefixedID{id}
return nil return nil
} }
@@ -64,7 +65,13 @@ func WithRootTenant(sid string) Option {
// WithRoles defines the role to action mapping. // WithRoles defines the role to action mapping.
func WithRoles(roles map[string][]string) Option { func WithRoles(roles map[string][]string) Option {
return func(s *service) error { return func(s *service) error {
s.roles = roles s.roles = make(map[string][]string, len(roles))
for role, actions := range roles {
slices.Sort(actions)
s.roles[role] = actions
}
return nil return nil
} }

View File

@@ -3,21 +3,23 @@ package service
import ( import (
"context" "context"
"go.infratographer.com/x/events"
"go.infratographer.com/x/gidx" "go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models" "go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
) )
const organizationEvent = "metal_organization"
// buildOrganizationRelationships compiles all relations into a relationships object to be processed by the processors.
func (s *service) buildOrganizationRelationships(org *models.OrganizationDetails) (Relationships, error) { func (s *service) buildOrganizationRelationships(org *models.OrganizationDetails) (Relationships, error) {
relations := Relationships{ relations := Relationships{
Relationships: []Relationship{
// Related org to the root tenant.
{
Resource: org, Resource: org,
Parent: Relation{
Relation: RelateParent, Relation: RelateParent,
RelatedResource: s.rootResource, Resource: s.rootResource,
},
}, },
SubjectRelation: RelateParent,
} }
for _, member := range org.Memberships { for _, member := range org.Memberships {
@@ -29,7 +31,6 @@ func (s *service) buildOrganizationRelationships(org *models.OrganizationDetails
} }
relations.Memberships = append(relations.Memberships, ResourceMemberships{ relations.Memberships = append(relations.Memberships, ResourceMemberships{
Resource: org,
Role: role, Role: role,
Member: member.User, Member: member.User,
}) })
@@ -37,32 +38,16 @@ func (s *service) buildOrganizationRelationships(org *models.OrganizationDetails
} }
for _, project := range org.Projects { for _, project := range org.Projects {
relations.Relationships = append(relations.Relationships, Relationship{ relations.SubjectRelationships = append(relations.SubjectRelationships, Relation{
Resource: project, Resource: project,
Relation: RelateParent, Relation: RelateParent,
RelatedResource: org,
}) })
for _, member := range project.Memberships {
for _, role := range member.Roles {
if _, ok := s.roles[role]; !ok {
s.logger.Warnf("unrecognized project role '%s' for %s on %s", role, member.User.PrefixedID(), project.PrefixedID())
continue
}
relations.Memberships = append(relations.Memberships, ResourceMemberships{
Resource: project,
Role: role,
Member: member.User,
})
}
}
} }
return relations, nil return relations, nil
} }
// IsOrganizationID checks if the provided id has the metal organization prefix.
func (s *service) IsOrganizationID(id gidx.PrefixedID) bool { func (s *service) IsOrganizationID(id gidx.PrefixedID) bool {
if idType, ok := s.idPrefixMap[id.Prefix()]; ok { if idType, ok := s.idPrefixMap[id.Prefix()]; ok {
return idType == TypeOrganization return idType == TypeOrganization
@@ -71,6 +56,7 @@ func (s *service) IsOrganizationID(id gidx.PrefixedID) bool {
return false return false
} }
// TouchOrganization initializes a sync for the provided organization id for relationships and memberships.
func (s *service) TouchOrganization(ctx context.Context, id gidx.PrefixedID) error { func (s *service) TouchOrganization(ctx context.Context, id gidx.PrefixedID) error {
logger := s.logger.With("organization.id", id.String()) logger := s.logger.With("organization.id", id.String())
@@ -88,14 +74,32 @@ func (s *service) TouchOrganization(ctx context.Context, id gidx.PrefixedID) err
return err return err
} }
s.processRelationships(ctx, "metal-relation", relationships.Relationships) relationshipChanges := s.processRelationships(ctx, organizationEvent, relationships)
s.processMemberships(ctx, relationships.Memberships) rolesChanged, assignmentsChanged := s.processMemberships(ctx, relationships, false)
s.logger.Infow("organization sync complete", "relationships", len(relationships.Relationships), "memberships", len(relationships.Memberships)) s.logger.Infow("organization sync complete",
"resource.id", org.PrefixedID(),
"relationships.changed", relationshipChanges,
"membership.roles_changed", rolesChanged,
"membership.assignments_changed", assignmentsChanged,
)
return nil return nil
} }
// DeleteOrganization deletes the provided organization id.
func (s *service) DeleteOrganization(ctx context.Context, id gidx.PrefixedID) error { func (s *service) DeleteOrganization(ctx context.Context, id gidx.PrefixedID) error {
err := s.publisher.PublishChange(ctx, organizationEvent, events.ChangeMessage{
SubjectID: id,
EventType: string(events.DeleteChangeType),
})
if err != nil {
s.logger.Errorw("error publishing organization delete",
"subject_type", organizationEvent,
"resource.id", id,
"error", err,
)
}
return nil return nil
} }

View File

@@ -0,0 +1,357 @@
package service_test
import (
"context"
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"go.infratographer.com/x/events"
"go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
"go.equinixmetal.net/infra9-metal-bridge/internal/permissions"
"go.equinixmetal.net/infra9-metal-bridge/internal/service"
)
type mockPublisher struct {
mock.Mock
}
func (p *mockPublisher) PublishChange(ctx context.Context, subjectType string, change events.ChangeMessage) error {
args := p.Called(subjectType, change)
return args.Error(0)
}
func TestTouchOrganizationEmpty(t *testing.T) {
rootTenantID := gidx.PrefixedID("tnntten-root1")
roleMap := map[string][]string{
"owner": {
"action1",
"action2",
},
}
userID := gidx.PrefixedID("idntusr-2aPXqbgfYSlld36XLfvNjEqUIIL9ekRZzgs2eBGOCBw")
user := &models.UserDetails{
ID: "usr1",
}
projectID := gidx.PrefixedID("metlprj-prj1")
project := &models.ProjectDetails{
ID: "prj1",
}
orgID := gidx.PrefixedID("metlorg-org1")
org := &models.OrganizationDetails{
ID: "org1",
Projects: []*models.ProjectDetails{
project,
},
Memberships: []*models.Membership[models.OrganizationDetails]{
{
User: user,
Roles: []string{
"owner",
},
},
},
}
var (
mMetal = new(providers.MockProvider)
mPerms = new(permissions.MockClient)
mPublisher = new(mockPublisher)
)
// Relationships
mMetal.On("GetOrganizationDetails", orgID).Return(org, nil)
mPerms.On("ListResourceRelationshipsFrom", orgID).Return([]permissions.ResourceRelationship{}, nil)
mPerms.On("ListResourceRelationshipsTo", orgID).Return([]permissions.ResourceRelationship{}, nil)
orgTenantChangeMessage := events.ChangeMessage{
SubjectID: orgID,
EventType: string(events.CreateChangeType),
AdditionalSubjectIDs: []gidx.PrefixedID{
rootTenantID,
},
}
relateProjectChangeMessage := events.ChangeMessage{
SubjectID: projectID,
EventType: string(events.CreateChangeType),
AdditionalSubjectIDs: []gidx.PrefixedID{
orgID,
},
}
mPublisher.On("PublishChange", "metal_organization", orgTenantChangeMessage).Return(nil)
mPublisher.On("PublishChange", "metal_organization", relateProjectChangeMessage).Return(nil)
// Memberships
newRoleID := gidx.PrefixedID("permrol-role1")
mPerms.On("ListResourceRoles", orgID).Return(permissions.ResourceRoles{}, nil)
mPerms.On("CreateRole", orgID, roleMap["owner"]).Return(newRoleID, nil)
mPerms.On("AssignRole", newRoleID, userID).Return(nil)
// Run scenario
svc, err := service.New(mPublisher, mMetal, mPerms,
service.WithRootTenant(rootTenantID.String()),
service.WithRoles(roleMap),
)
require.NoError(t, err)
err = svc.TouchOrganization(context.Background(), orgID)
require.NoError(t, err)
require.True(t, mMetal.AssertExpectations(t), "unexpected calls to metal provider")
require.True(t, mPerms.AssertExpectations(t), "unexpected calls to permissions client")
require.True(t, mPublisher.AssertExpectations(t), "unexpected calls to events publisher")
}
func TestTouchOrganizationCleanup(t *testing.T) {
oldRootTenantID := gidx.PrefixedID("tnntten-root1")
newRootTenantID := gidx.PrefixedID("tnntten-root2")
roleMap := map[string][]string{
"owner": {
"action1",
"action2",
},
"collaborator": {
"action1",
},
}
oldUserID := gidx.PrefixedID("idntusr-2aPXqbgfYSlld36XLfvNjEqUIIL9ekRZzgs2eBGOCBw")
deadProjectID := gidx.PrefixedID("metlprj-prj1")
userID := gidx.PrefixedID("idntusr-Ewj_PJUue9eDIDoyCoWG48GtwlysqTj2Y4qWPiJPN1s")
user := &models.UserDetails{
ID: "usr2",
}
projectID := gidx.PrefixedID("metlprj-prj2")
project := &models.ProjectDetails{
ID: "prj2",
}
orgID := gidx.PrefixedID("metlorg-org1")
org := &models.OrganizationDetails{
ID: "org1",
Projects: []*models.ProjectDetails{
project,
},
Memberships: []*models.Membership[models.OrganizationDetails]{
{
User: user,
Roles: []string{
"owner",
},
},
},
}
var (
mMetal = new(providers.MockProvider)
mPerms = new(permissions.MockClient)
mPublisher = new(mockPublisher)
)
// Relationships
mMetal.On("GetOrganizationDetails", orgID).Return(org, nil)
existingRelsFrom := []permissions.ResourceRelationship{
{
Relation: string(service.RelateParent),
SubjectID: oldRootTenantID,
},
}
mPerms.On("ListResourceRelationshipsFrom", orgID).Return(existingRelsFrom, nil)
existingRelsTo := []permissions.ResourceRelationship{
{
ResourceID: deadProjectID,
Relation: string(service.RelateParent),
},
}
mPerms.On("ListResourceRelationshipsTo", orgID).Return(existingRelsTo, nil)
mPerms.On("DeleteResourceRelationship", orgID, string(service.RelateParent), oldRootTenantID).Return(nil)
mPerms.On("DeleteResourceRelationship", deadProjectID, string(service.RelateParent), orgID).Return(nil)
orgTenantChangeMessage := events.ChangeMessage{
SubjectID: orgID,
EventType: string(events.CreateChangeType),
AdditionalSubjectIDs: []gidx.PrefixedID{
newRootTenantID,
},
}
relateProjectChangeMessage := events.ChangeMessage{
SubjectID: projectID,
EventType: string(events.CreateChangeType),
AdditionalSubjectIDs: []gidx.PrefixedID{
orgID,
},
}
mPublisher.On("PublishChange", "metal_organization", orgTenantChangeMessage).Return(nil)
mPublisher.On("PublishChange", "metal_organization", relateProjectChangeMessage).Return(nil)
// Memberships
oldRoleID := gidx.PrefixedID("permrol-role1")
newRoleID := gidx.PrefixedID("permrol-role2")
existingRoles := permissions.ResourceRoles{
{
ID: oldRoleID,
Actions: roleMap["collaborator"],
},
}
mPerms.On("ListResourceRoles", orgID).Return(existingRoles, nil)
existingRoleAssignments := []gidx.PrefixedID{
oldUserID,
}
mPerms.On("ListRoleAssignments", oldRoleID).Return(existingRoleAssignments, nil)
mPerms.On("CreateRole", orgID, roleMap["owner"]).Return(newRoleID, nil)
mPerms.On("DeleteRole", oldRoleID).Return(nil)
mPerms.On("AssignRole", newRoleID, userID).Return(nil)
mPerms.On("UnassignRole", oldRoleID, oldUserID).Return(nil)
// Run scenario
svc, err := service.New(mPublisher, mMetal, mPerms,
service.WithRootTenant(newRootTenantID.String()),
service.WithRoles(roleMap),
)
require.NoError(t, err)
err = svc.TouchOrganization(context.Background(), orgID)
require.NoError(t, err)
require.True(t, mMetal.AssertExpectations(t), "unexpected calls to metal provider")
require.True(t, mPerms.AssertExpectations(t), "unexpected calls to permissions client")
require.True(t, mPublisher.AssertExpectations(t), "unexpected calls to events publisher")
}
func TestTouchOrganizationNoChange(t *testing.T) {
rootTenantID := gidx.PrefixedID("tnntten-root1")
roleMap := map[string][]string{
"owner": {
"action1",
"action2",
},
}
userID := gidx.PrefixedID("idntusr-2aPXqbgfYSlld36XLfvNjEqUIIL9ekRZzgs2eBGOCBw")
user := &models.UserDetails{
ID: "usr1",
}
projectID := gidx.PrefixedID("metlprj-prj1")
project := &models.ProjectDetails{
ID: "prj1",
}
orgID := gidx.PrefixedID("metlorg-org1")
org := &models.OrganizationDetails{
ID: "org1",
Projects: []*models.ProjectDetails{
project,
},
Memberships: []*models.Membership[models.OrganizationDetails]{
{
User: user,
Roles: []string{
"owner",
},
},
},
}
var (
mMetal = new(providers.MockProvider)
mPerms = new(permissions.MockClient)
mPublisher = new(mockPublisher)
)
// Relationships
mMetal.On("GetOrganizationDetails", orgID).Return(org, nil)
existingRelsFrom := []permissions.ResourceRelationship{
{
Relation: string(service.RelateParent),
SubjectID: rootTenantID,
},
}
mPerms.On("ListResourceRelationshipsFrom", orgID).Return(existingRelsFrom, nil)
existingRelsTo := []permissions.ResourceRelationship{
{
ResourceID: projectID,
Relation: string(service.RelateParent),
},
}
mPerms.On("ListResourceRelationshipsTo", orgID).Return(existingRelsTo, nil)
// Memberships
roleID := gidx.PrefixedID("permrol-role1")
existingRoles := permissions.ResourceRoles{
{
ID: roleID,
Actions: roleMap["owner"],
},
}
mPerms.On("ListResourceRoles", orgID).Return(existingRoles, nil)
existingRoleAssignments := []gidx.PrefixedID{
userID,
}
mPerms.On("ListRoleAssignments", roleID).Return(existingRoleAssignments, nil)
// Run scenario
svc, err := service.New(mPublisher, mMetal, mPerms,
service.WithRootTenant(rootTenantID.String()),
service.WithRoles(roleMap),
)
require.NoError(t, err)
err = svc.TouchOrganization(context.Background(), orgID)
require.NoError(t, err)
require.True(t, mMetal.AssertExpectations(t), "unexpected calls to metal provider")
require.True(t, mPerms.AssertExpectations(t), "unexpected calls to permissions client")
require.True(t, mPublisher.AssertExpectations(t), "unexpected calls to events publisher")
}

View File

@@ -1,137 +0,0 @@
package service
import (
"context"
"go.infratographer.com/x/events"
"go.infratographer.com/x/gidx"
)
func (s *service) processRelationships(ctx context.Context, subjectType string, relationships []Relationship) {
var err error
for _, rel := range relationships {
err = s.publisher.PublishChange(ctx, subjectType, events.ChangeMessage{
SubjectID: rel.Resource.PrefixedID(),
EventType: string(events.CreateChangeType),
AdditionalSubjectIDs: []gidx.PrefixedID{
rel.RelatedResource.PrefixedID(),
},
})
if err != nil {
s.logger.Errorw("error publishing change",
"subject_type", subjectType,
"resource.id", rel.Resource.PrefixedID(),
"related_resource.id", rel.RelatedResource.PrefixedID(),
"error", err,
)
}
}
}
func (s *service) processMemberships(ctx context.Context, memberships []ResourceMemberships) {
resourceRoleID := make(map[gidx.PrefixedID]map[string]gidx.PrefixedID)
resourceRoleMembers := make(map[gidx.PrefixedID]map[string]map[gidx.PrefixedID]bool)
roleActions := make(map[string][]string)
for _, membership := range memberships {
resourceID := membership.Resource.PrefixedID()
role := membership.Role
memberID := membership.Member.PrefixedID()
if _, ok := resourceRoleMembers[resourceID]; !ok {
resourceRoleID[resourceID] = make(map[string]gidx.PrefixedID)
resourceRoleMembers[resourceID] = make(map[string]map[gidx.PrefixedID]bool)
}
if _, ok := resourceRoleMembers[resourceID][role]; !ok {
resourceRoleMembers[resourceID][role] = make(map[gidx.PrefixedID]bool)
roleActions[role] = s.roles[role]
}
resourceRoleMembers[resourceID][role][memberID] = true
}
resourceRoleAssignments := make(map[gidx.PrefixedID]map[gidx.PrefixedID]map[gidx.PrefixedID]bool)
for resourceID, roles := range resourceRoleID {
resourceRoleAssignments[resourceID] = make(map[gidx.PrefixedID]map[gidx.PrefixedID]bool)
for role := range roles {
actions := roleActions[role]
resourceRole, err := s.perms.FindResourceRoleByActions(ctx, resourceID, actions)
if err != nil {
s.logger.Warnw("failed to find role by actions for resource", "resource.id", resourceID, "role", role, "actions", actions, "error", err)
continue
}
resourceRoleID[resourceID][role] = resourceRole.ID
resourceRoleAssignments[resourceID][resourceRole.ID] = make(map[gidx.PrefixedID]bool)
assignments, err := s.perms.ListRoleAssignments(ctx, resourceRole.ID)
if err != nil {
s.logger.Warnw("failed to get role assignments for resource", "resource.id", resourceID, "role", role, "error", err)
continue
}
for _, assignment := range assignments {
resourceRoleAssignments[resourceID][resourceRole.ID][assignment] = true
}
}
}
for resourceID, roles := range resourceRoleMembers {
for role, members := range roles {
roleID := resourceRoleID[resourceID][role]
actions := roleActions[role]
logger := s.logger.With("resource.id", resourceID, "role.name", role, "actions", actions)
var createdRole bool
if roleID == gidx.NullPrefixedID {
logger.Infow("creating role for resource")
resourceRoleID, err := s.perms.CreateRole(ctx, resourceID, actions)
if err != nil {
logger.Errorw("failed to create role for resource", "error", err)
continue
}
createdRole = true
roleID = resourceRoleID
}
logger = logger.With("role.id", roleID)
assignments := make(map[gidx.PrefixedID]bool)
if !createdRole {
assignments = resourceRoleAssignments[resourceID][roleID]
}
for memberID := range members {
mlogger := logger.With("member.id", memberID)
if _, ok := assignments[memberID]; ok {
mlogger.Infow("skipping already assigned member")
continue
}
if err := s.perms.AssignRole(ctx, roleID, memberID); err != nil {
mlogger.Errorw("failed to assign member to role", "error", err)
continue
}
mlogger.Infow("role assigned to member")
}
}
}
}

View File

@@ -0,0 +1,235 @@
package service
import (
"context"
"strings"
"go.infratographer.com/x/gidx"
"golang.org/x/exp/slices"
"go.equinixmetal.net/infra9-metal-bridge/internal/permissions"
)
// processMemberships determines the changes between what is wanted and what is live and executes on the differences.
// If skipDeletions is true, no deletes will be executed.
func (s *service) processMemberships(ctx context.Context, relationships Relationships, skipDeletions bool) (int, int) {
if len(relationships.Memberships) == 0 {
return 0, 0
}
rlogger := s.logger.With("resource.id", relationships.Resource.PrefixedID())
roleIDs := make(map[string]gidx.PrefixedID)
var (
totalRoleCreate, totalRoleDelete int
totalRoleAssign, totalRoleUnassign int
)
wantRoles, wantAssignments := s.mapResourceWants(relationships.Memberships)
liveRoles, liveAssignments, err := s.mapResourceDetails(ctx, relationships.Resource.PrefixedID())
if err != nil {
rlogger.Errorw("failed to get membership resource details map",
"error", err,
)
return 0, 0
}
roleCreations := make(map[string][]string)
roleDeletions := make([]gidx.PrefixedID, 0)
for roleKey, actions := range wantRoles {
if _, ok := liveRoles[roleKey]; !ok {
roleCreations[roleKey] = actions
}
}
for roleKey, role := range liveRoles {
if !skipDeletions {
if _, ok := wantRoles[roleKey]; !ok {
roleDeletions = append(roleDeletions, role.ID)
}
}
roleIDs[roleKey] = role.ID
}
roleMembershipsAdd := make(map[string][]gidx.PrefixedID)
roleMembershipsRemove := make(map[string][]gidx.PrefixedID)
for roleKey, assignments := range wantAssignments {
for memberID := range assignments {
if _, ok := liveAssignments[roleKey]; ok {
if _, ok := liveAssignments[roleKey][memberID]; ok {
continue
}
}
roleMembershipsAdd[roleKey] = append(roleMembershipsAdd[roleKey], memberID)
totalRoleAssign++
}
}
if !skipDeletions {
for roleKey, assignments := range liveAssignments {
for memberID := range assignments {
if _, ok := wantAssignments[roleKey]; ok {
if _, ok := wantAssignments[roleKey][memberID]; ok {
continue
}
}
roleMembershipsRemove[roleKey] = append(roleMembershipsRemove[roleKey], memberID)
totalRoleUnassign++
}
}
}
totalRoleCreate += len(roleCreations)
totalRoleDelete += len(roleDeletions)
rlogger.Debugw("processing memberships",
"role.create", totalRoleCreate,
"role.delete", totalRoleDelete,
"role.assign", totalRoleAssign,
"role.unassign", totalRoleUnassign,
)
var (
rolesCreated, rolesDeleted int
roleAssignments, roleUnassignments int
)
for roleKey, actions := range roleCreations {
roleID, err := s.perms.CreateRole(ctx, relationships.Resource.PrefixedID(), actions)
if err != nil {
rlogger.Errorw("error creating role", "actions", actions, "error", err)
continue
}
roleIDs[roleKey] = roleID
rolesCreated++
}
for _, roleID := range roleDeletions {
if err := s.perms.DeleteRole(ctx, roleID); err != nil {
rlogger.Errorw("error deleting role", "role.id", roleID, "error", err)
continue
}
rolesDeleted++
}
for roleKey, members := range roleMembershipsAdd {
roleID, ok := roleIDs[roleKey]
if !ok {
rlogger.Errorw("role id not found for role actions key", "role_actions_key", roleKey, "members", len(members))
continue
}
for _, memberID := range members {
if err := s.perms.AssignRole(ctx, roleID, memberID); err != nil {
rlogger.Errorw("error assigning member to role", "role.id", roleID, "member.id", memberID, "error", err)
continue
}
roleAssignments++
}
}
for roleKey, members := range roleMembershipsRemove {
roleID, ok := roleIDs[roleKey]
if !ok {
rlogger.Errorw("role id not found for role actions key", "role_actions_key", roleKey, "members", len(members))
continue
}
for _, memberID := range members {
if err := s.perms.UnassignRole(ctx, roleID, memberID); err != nil {
rlogger.Errorw("error removing member from role", "role.id", roleID, "member.id", memberID, "error", err)
continue
}
roleUnassignments++
}
}
rlogger.Debugw("memberships processed",
"role.create", rolesCreated,
"role.delete", rolesDeleted,
"role.assign", roleAssignments,
"role.unassign", roleUnassignments,
)
return rolesCreated + rolesDeleted, roleAssignments + roleUnassignments
}
// mapResourceWants processes the provided memberships and returns two maps.
// A Role Key is computed based on a sorted slice of actions for each role.
// The first map is of Role Key -> list of actions
// The second map is of Role Key -> Member ID -> true
func (s *service) mapResourceWants(memberships []ResourceMemberships) (map[string][]string, map[string]map[gidx.PrefixedID]bool) {
roleActionsKey := make(map[string]string)
for role, actions := range s.roles {
roleActionsKey[role] = strings.Join(actions, "|")
}
wantRoles := make(map[string][]string)
wantAssignments := make(map[string]map[gidx.PrefixedID]bool)
for _, membership := range memberships {
roleKey := roleActionsKey[membership.Role]
if _, ok := wantRoles[roleKey]; !ok {
wantRoles[roleKey] = s.roles[membership.Role]
wantAssignments[roleKey] = make(map[gidx.PrefixedID]bool)
}
wantAssignments[roleKey][membership.Member.PrefixedID()] = true
}
return wantRoles, wantAssignments
}
// mapResourceDetails fetches the provided ResourceID's live state and returns two maps and an error.
// A Role Key is computed based on a sorted slice of actions for each role.
// The first map is of Role Key -> Permissions Resource Role
// The second map is of Role Key -> Member ID -> true
func (s *service) mapResourceDetails(ctx context.Context, resourceID gidx.PrefixedID) (map[string]permissions.ResourceRole, map[string]map[gidx.PrefixedID]bool, error) {
roles := make(map[string]permissions.ResourceRole)
assignments := make(map[string]map[gidx.PrefixedID]bool)
liveRoles, err := s.perms.ListResourceRoles(ctx, resourceID)
if err != nil {
return nil, nil, err
}
for _, role := range liveRoles {
slices.Sort(role.Actions)
roleKey := strings.Join(role.Actions, "|")
roles[roleKey] = role
liveAssignments, err := s.perms.ListRoleAssignments(ctx, role.ID)
if err != nil {
return nil, nil, err
}
assignments[roleKey] = make(map[gidx.PrefixedID]bool)
for _, assignment := range liveAssignments {
assignments[roleKey][assignment] = true
}
}
return roles, assignments, nil
}

View File

@@ -0,0 +1,216 @@
package service
import (
"context"
"go.infratographer.com/x/events"
"go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/permissions"
)
// processRelationships determines the changes between what is wanted and what is live and executes on the differences.
// Relationship creations use events.
// Relationship deletions use the api, as delete events delete all related resources and not just the provided ones.
func (s *service) processRelationships(ctx context.Context, eventType string, relationships Relationships) int {
rlogger := s.logger.With("resource.id", relationships.Resource.PrefixedID())
wantParentRelationship, wantSubjectRelationships := s.mapRelationWants(relationships)
liveParentRelationships, liveSubjectRelationships, err := s.getRelationshipMap(ctx, relationships.Resource, relationships.SubjectRelation)
if err != nil {
rlogger.Errorw("failed to get relationship map",
"relationships.subject_relation", relationships.SubjectRelation,
"error", err,
)
return 0
}
var (
createParentRelationship *Relation
deleteParentRelationships []gidx.PrefixedID
foundParent bool
createSubjectRelationships []Relation
deleteSubjectRelationships []Relation
)
if wantParentRelationship != nil {
for subjID := range liveParentRelationships {
if subjID == wantParentRelationship.Resource.PrefixedID() {
foundParent = true
continue
}
deleteParentRelationships = append(deleteParentRelationships, subjID)
}
if !foundParent {
createParentRelationship = wantParentRelationship
}
} else {
for subjID := range liveParentRelationships {
deleteParentRelationships = append(deleteParentRelationships, subjID)
}
}
for resID, relation := range wantSubjectRelationships {
if _, ok := liveSubjectRelationships[resID]; ok {
continue
}
createSubjectRelationships = append(createSubjectRelationships, Relation{
Resource: prefixedID{resID},
Relation: relation,
})
}
for resID, relation := range liveSubjectRelationships {
if _, ok := wantSubjectRelationships[resID]; ok {
continue
}
deleteSubjectRelationships = append(deleteSubjectRelationships, Relation{
Resource: prefixedID{resID},
Relation: relation,
})
}
var processEvents []events.ChangeMessage
rlogger.Debugw("processing relationships",
"parent.create", createParentRelationship != nil,
"parent.delete", len(deleteParentRelationships),
"subject.create", len(createSubjectRelationships),
"subject.delete", len(deleteSubjectRelationships),
)
if createParentRelationship != nil {
processEvents = append(processEvents, events.ChangeMessage{
SubjectID: relationships.Resource.PrefixedID(),
EventType: string(events.CreateChangeType),
AdditionalSubjectIDs: []gidx.PrefixedID{
createParentRelationship.Resource.PrefixedID(),
},
})
}
for _, relation := range createSubjectRelationships {
processEvents = append(processEvents, events.ChangeMessage{
SubjectID: relation.Resource.PrefixedID(),
EventType: string(events.CreateChangeType),
AdditionalSubjectIDs: []gidx.PrefixedID{
relationships.Resource.PrefixedID(),
},
})
}
for _, relatedResourceID := range deleteParentRelationships {
err = s.perms.DeleteResourceRelationship(ctx, relationships.Resource.PrefixedID(), string(RelateParent), relatedResourceID)
if err != nil {
rlogger.Errorw("error deleting parent relationship",
"parent.resource.id", relatedResourceID.String(),
)
}
}
for _, relation := range deleteSubjectRelationships {
err = s.perms.DeleteResourceRelationship(ctx, relation.Resource.PrefixedID(), string(relation.Relation), relationships.Resource.PrefixedID())
if err != nil {
rlogger.Errorw("error deleting relationship",
"relation", relation.Relation,
"subject.id", relation.Resource.PrefixedID().String(),
)
}
}
for _, event := range processEvents {
err = s.publisher.PublishChange(ctx, eventType, event)
if err != nil {
rlogger.Errorw("error publishing change",
"subject_type", eventType,
"subject.id", event.SubjectID,
"event.type", event.EventType,
"additional_subject_ids", event.AdditionalSubjectIDs,
"error", err,
)
}
}
rlogger.Debugw("relationships processed",
"parent.create", createParentRelationship != nil,
"parent.delete", len(deleteParentRelationships),
"subject.create", len(createSubjectRelationships),
"subject.delete", len(deleteSubjectRelationships),
)
changes := len(deleteParentRelationships) + len(createSubjectRelationships) + len(deleteSubjectRelationships)
if createParentRelationship != nil {
changes++
}
return changes
}
// mapRelationWants returns the parent relation if provided and a map of Subjects -> relation.
func (s *service) mapRelationWants(relationships Relationships) (*Relation, map[gidx.PrefixedID]RelationshipType) {
var wantParent *Relation
wantSubject := make(map[gidx.PrefixedID]RelationshipType)
if relationships.Parent.Resource != nil {
wantParent = &relationships.Parent
}
for _, relationship := range relationships.SubjectRelationships {
wantSubject[relationship.Resource.PrefixedID()] = relationship.Relation
}
return wantParent, wantSubject
}
// getRelationshipMap fetches the provided resources relationships, as the source resource and the destination subject.
// Returned are two maps, the first maps Subject IDs -> Relationship
// The second map, maps Resource IDs -> relationship
func (s *service) getRelationshipMap(ctx context.Context, resource IDPrefixableResource, relation RelationshipType) (map[gidx.PrefixedID]RelationshipType, map[gidx.PrefixedID]RelationshipType, error) {
liveResource, err := s.perms.ListResourceRelationshipsFrom(ctx, resource.PrefixedID())
if err != nil {
return nil, nil, err
}
var liveSubject []permissions.ResourceRelationship
if relation != "" {
liveSubject, err = s.perms.ListResourceRelationshipsTo(ctx, resource.PrefixedID())
if err != nil {
return nil, nil, err
}
}
parents := make(map[gidx.PrefixedID]RelationshipType, len(liveResource))
for _, relationship := range liveResource {
if relationship.Relation != string(RelateParent) {
continue
}
parents[relationship.SubjectID] = RelationshipType(relationship.Relation)
}
subject := make(map[gidx.PrefixedID]RelationshipType, len(liveSubject))
for _, relationship := range liveSubject {
if relationship.Relation != string(relation) {
continue
}
subject[relationship.ResourceID] = RelationshipType(relationship.Relation)
}
return parents, subject, nil
}

View File

@@ -3,9 +3,44 @@ package service
import ( import (
"context" "context"
"go.infratographer.com/x/events"
"go.infratographer.com/x/gidx" "go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
) )
const projectEvent = "metal_project"
// buildProjectRelationships compiles all relations into a relationships object to be processed by the processors.
func (s *service) buildProjectRelationships(project *models.ProjectDetails) (Relationships, error) {
relations := Relationships{
Resource: project,
// Relate project to organization.
Parent: Relation{
Resource: project.Organization,
Relation: RelateParent,
},
}
for _, member := range project.Memberships {
for _, role := range member.Roles {
if _, ok := s.roles[role]; !ok {
s.logger.Warnf("unrecognized project role '%s' for %s on %s", role, member.User.PrefixedID(), project.PrefixedID())
continue
}
relations.Memberships = append(relations.Memberships, ResourceMemberships{
Role: role,
Member: member.User,
})
}
}
return relations, nil
}
// IsProjectID checks if the provided id has the metal project prefix.
func (s *service) IsProjectID(id gidx.PrefixedID) bool { func (s *service) IsProjectID(id gidx.PrefixedID) bool {
if idType, ok := s.idPrefixMap[id.Prefix()]; ok { if idType, ok := s.idPrefixMap[id.Prefix()]; ok {
return idType == TypeProject return idType == TypeProject
@@ -14,10 +49,50 @@ func (s *service) IsProjectID(id gidx.PrefixedID) bool {
return false return false
} }
// TouchProject initializes a sync for the provided project id for relationships and memberships.
func (s *service) TouchProject(ctx context.Context, id gidx.PrefixedID) error { func (s *service) TouchProject(ctx context.Context, id gidx.PrefixedID) error {
logger := s.logger.With("project.id", id.String())
project, err := s.metal.GetProjectDetails(ctx, id)
if err != nil {
logger.Errorw("failed to get project", "error", err)
return err
}
relationships, err := s.buildProjectRelationships(project)
if err != nil {
logger.Errorw("failed to build project relationships", "error", err)
return err
}
relationshipChanges := s.processRelationships(ctx, projectEvent, relationships)
rolesChanged, assignmentsChanged := s.processMemberships(ctx, relationships, false)
s.logger.Infow("project sync complete",
"resource.id", project.PrefixedID(),
"relationships.changed", relationshipChanges,
"membership.roles_changed", rolesChanged,
"membership.assignments_changed", assignmentsChanged,
)
return nil return nil
} }
// DeleteProject deletes the provided project id.
func (s *service) DeleteProject(ctx context.Context, id gidx.PrefixedID) error { func (s *service) DeleteProject(ctx context.Context, id gidx.PrefixedID) error {
err := s.publisher.PublishChange(ctx, projectEvent, events.ChangeMessage{
SubjectID: id,
EventType: string(events.DeleteChangeType),
})
if err != nil {
s.logger.Errorw("error publishing project delete",
"subject_type", projectEvent,
"resource.id", id,
"error", err,
)
}
return nil return nil
} }

View File

@@ -0,0 +1,303 @@
package service_test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.infratographer.com/x/events"
"go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/providers"
"go.equinixmetal.net/infra9-metal-bridge/internal/permissions"
"go.equinixmetal.net/infra9-metal-bridge/internal/service"
)
func TestTouchProjectEmpty(t *testing.T) {
rootTenantID := gidx.PrefixedID("tnntten-root1")
roleMap := map[string][]string{
"owner": {
"action1",
"action2",
},
}
userID := gidx.PrefixedID("idntusr-2aPXqbgfYSlld36XLfvNjEqUIIL9ekRZzgs2eBGOCBw")
user := &models.UserDetails{
ID: "usr1",
}
orgID := gidx.PrefixedID("metlorg-org1")
org := &models.OrganizationDetails{
ID: "org1",
}
projectID := gidx.PrefixedID("metlprj-prj1")
project := &models.ProjectDetails{
ID: "prj1",
Organization: org,
Memberships: []*models.Membership[models.ProjectDetails]{
{
User: user,
Roles: []string{
"owner",
},
},
},
}
var (
mMetal = new(providers.MockProvider)
mPerms = new(permissions.MockClient)
mPublisher = new(mockPublisher)
)
// Relationships
mMetal.On("GetProjectDetails", projectID).Return(project, nil)
mPerms.On("ListResourceRelationshipsFrom", projectID).Return([]permissions.ResourceRelationship{}, nil)
relateParentChangeMessage := events.ChangeMessage{
SubjectID: projectID,
EventType: string(events.CreateChangeType),
AdditionalSubjectIDs: []gidx.PrefixedID{
orgID,
},
}
mPublisher.On("PublishChange", "metal_project", relateParentChangeMessage).Return(nil)
// Memberships
newRoleID := gidx.PrefixedID("permrol-role1")
mPerms.On("ListResourceRoles", projectID).Return(permissions.ResourceRoles{}, nil)
mPerms.On("CreateRole", projectID, roleMap["owner"]).Return(newRoleID, nil)
mPerms.On("AssignRole", newRoleID, userID).Return(nil)
// Run scenario
svc, err := service.New(mPublisher, mMetal, mPerms,
service.WithRootTenant(rootTenantID.String()),
service.WithRoles(roleMap),
)
require.NoError(t, err)
err = svc.TouchProject(context.Background(), projectID)
require.NoError(t, err)
require.True(t, mMetal.AssertExpectations(t), "unexpected calls to metal provider")
require.True(t, mPerms.AssertExpectations(t), "unexpected calls to permissions client")
require.True(t, mPublisher.AssertExpectations(t), "unexpected calls to events publisher")
}
func TestTouchProjectCleanup(t *testing.T) {
rootTenantID := gidx.PrefixedID("tnntten-root1")
roleMap := map[string][]string{
"owner": {
"action1",
"action2",
},
"collaborator": {
"action1",
},
}
oldUserID := gidx.PrefixedID("idntusr-2aPXqbgfYSlld36XLfvNjEqUIIL9ekRZzgs2eBGOCBw")
oldOrgID := gidx.PrefixedID("metlorg-org1")
userID := gidx.PrefixedID("idntusr-Ewj_PJUue9eDIDoyCoWG48GtwlysqTj2Y4qWPiJPN1s")
user := &models.UserDetails{
ID: "usr2",
}
orgID := gidx.PrefixedID("metlorg-org2")
org := &models.OrganizationDetails{
ID: "org2",
}
projectID := gidx.PrefixedID("metlprj-prj2")
project := &models.ProjectDetails{
ID: "prj2",
Organization: org,
Memberships: []*models.Membership[models.ProjectDetails]{
{
User: user,
Roles: []string{
"owner",
},
},
},
}
var (
mMetal = new(providers.MockProvider)
mPerms = new(permissions.MockClient)
mPublisher = new(mockPublisher)
)
// Relationships
mMetal.On("GetProjectDetails", projectID).Return(project, nil)
existingRelsFrom := []permissions.ResourceRelationship{
{
Relation: string(service.RelateParent),
SubjectID: oldOrgID,
},
}
mPerms.On("ListResourceRelationshipsFrom", projectID).Return(existingRelsFrom, nil)
mPerms.On("DeleteResourceRelationship", projectID, string(service.RelateParent), oldOrgID).Return(nil)
relateParentChangeMessage := events.ChangeMessage{
SubjectID: projectID,
EventType: string(events.CreateChangeType),
AdditionalSubjectIDs: []gidx.PrefixedID{
orgID,
},
}
mPublisher.On("PublishChange", "metal_project", relateParentChangeMessage).Return(nil)
// Memberships
oldRoleID := gidx.PrefixedID("permrol-role1")
newRoleID := gidx.PrefixedID("permrol-role2")
existingRoles := permissions.ResourceRoles{
{
ID: oldRoleID,
Actions: roleMap["collaborator"],
},
}
mPerms.On("ListResourceRoles", projectID).Return(existingRoles, nil)
existingRoleAssignments := []gidx.PrefixedID{
oldUserID,
}
mPerms.On("ListRoleAssignments", oldRoleID).Return(existingRoleAssignments, nil)
mPerms.On("CreateRole", projectID, roleMap["owner"]).Return(newRoleID, nil)
mPerms.On("DeleteRole", oldRoleID).Return(nil)
mPerms.On("AssignRole", newRoleID, userID).Return(nil)
mPerms.On("UnassignRole", oldRoleID, oldUserID).Return(nil)
// Run scenario
svc, err := service.New(mPublisher, mMetal, mPerms,
service.WithRootTenant(rootTenantID.String()),
service.WithRoles(roleMap),
)
require.NoError(t, err)
err = svc.TouchProject(context.Background(), projectID)
require.NoError(t, err)
require.True(t, mMetal.AssertExpectations(t), "unexpected calls to metal provider")
require.True(t, mPerms.AssertExpectations(t), "unexpected calls to permissions client")
require.True(t, mPublisher.AssertExpectations(t), "unexpected calls to events publisher")
}
func TestTouchProjectNoChange(t *testing.T) {
rootTenantID := gidx.PrefixedID("tnntten-root1")
roleMap := map[string][]string{
"owner": {
"action1",
"action2",
},
}
userID := gidx.PrefixedID("idntusr-2aPXqbgfYSlld36XLfvNjEqUIIL9ekRZzgs2eBGOCBw")
user := &models.UserDetails{
ID: "usr1",
}
orgID := gidx.PrefixedID("metlorg-org1")
org := &models.OrganizationDetails{
ID: "org1",
}
projectID := gidx.PrefixedID("metlprj-prj1")
project := &models.ProjectDetails{
ID: "prj1",
Organization: org,
Memberships: []*models.Membership[models.ProjectDetails]{
{
User: user,
Roles: []string{
"owner",
},
},
},
}
var (
mMetal = new(providers.MockProvider)
mPerms = new(permissions.MockClient)
mPublisher = new(mockPublisher)
)
// Relationships
mMetal.On("GetProjectDetails", projectID).Return(project, nil)
existingRelsFrom := []permissions.ResourceRelationship{
{
Relation: string(service.RelateParent),
SubjectID: orgID,
},
}
mPerms.On("ListResourceRelationshipsFrom", projectID).Return(existingRelsFrom, nil)
// Memberships
roleID := gidx.PrefixedID("permrol-role1")
existingRoles := permissions.ResourceRoles{
{
ID: roleID,
Actions: roleMap["owner"],
},
}
mPerms.On("ListResourceRoles", projectID).Return(existingRoles, nil)
existingRoleAssignments := []gidx.PrefixedID{
userID,
}
mPerms.On("ListRoleAssignments", roleID).Return(existingRoleAssignments, nil)
// Run scenario
svc, err := service.New(mPublisher, mMetal, mPerms,
service.WithRootTenant(rootTenantID.String()),
service.WithRoles(roleMap),
)
require.NoError(t, err)
err = svc.TouchProject(context.Background(), projectID)
require.NoError(t, err)
require.True(t, mMetal.AssertExpectations(t), "unexpected calls to metal provider")
require.True(t, mPerms.AssertExpectations(t), "unexpected calls to permissions client")
require.True(t, mPublisher.AssertExpectations(t), "unexpected calls to events publisher")
}

View File

@@ -2,63 +2,41 @@ package service
import ( import (
"go.infratographer.com/x/gidx" "go.infratographer.com/x/gidx"
"go.equinixmetal.net/infra9-metal-bridge/internal/metal/models"
) )
const ( const (
// RelateOwner is the owner relationship type.
RelateOwner RelationshipType = "owner" RelateOwner RelationshipType = "owner"
// RelateParent is the parent relationship type.
RelateParent RelationshipType = "parent" RelateParent RelationshipType = "parent"
) )
// RelationshipType are relationship types.
type RelationshipType string type RelationshipType string
// IDPrefixableResource ensures the the interface passed provides prefixed ids.
type IDPrefixableResource interface { type IDPrefixableResource interface {
PrefixedID() gidx.PrefixedID PrefixedID() gidx.PrefixedID
} }
// Relationships defines a resource and all possible relationships and memberships.
type Relationships struct { type Relationships struct {
Relationships []Relationship Resource IDPrefixableResource
Parent Relation
SubjectRelation RelationshipType
SubjectRelationships []Relation
Memberships []ResourceMemberships Memberships []ResourceMemberships
} }
func (r Relationships) DeDupe() Relationships { // Relation defines a relation to a resource.
rels := make(map[string]bool) type Relation struct {
mems := make(map[string]bool)
var results Relationships
for _, rel := range r.Relationships {
key := rel.Resource.PrefixedID().String() + "/" + string(rel.Relation) + "/" + rel.RelatedResource.PrefixedID().String()
if _, ok := rels[key]; !ok {
rels[key] = true
results.Relationships = append(results.Relationships, rel)
}
}
for _, member := range r.Memberships {
key := member.Resource.PrefixedID().String() + "/" + member.Role + "/" + member.Member.PrefixedID().String()
if _, ok := mems[key]; !ok {
mems[key] = true
results.Memberships = append(results.Memberships, member)
}
}
return results
}
type Relationship struct {
Resource IDPrefixableResource
Relation RelationshipType Relation RelationshipType
RelatedResource IDPrefixableResource Resource IDPrefixableResource
} }
// ResourceMemberships defines a member and role.
type ResourceMemberships struct { type ResourceMemberships struct {
Resource IDPrefixableResource
Role string Role string
Member *models.UserDetails Member IDPrefixableResource
} }

View File

@@ -13,20 +13,42 @@ import (
const ( const (
// TypeOrganization defines the organization type. // TypeOrganization defines the organization type.
TypeOrganization = "organization" TypeOrganization ObjectType = "organization"
// TypeProject defines the project type. // TypeProject defines the project type.
TypeProject = "project" TypeProject ObjectType = "project"
// TypeUser defines the user type. // TypeUser defines the user type.
TypeUser = "user" TypeUser ObjectType = "user"
) )
// DefaultPrefixMap is the default id prefix to type relationship. // DefaultPrefixMap is the default id prefix to type relationship.
var DefaultPrefixMap = map[string]string{ var DefaultPrefixMap = map[string]ObjectType{
"metlorg": TypeOrganization, TypeOrganization.Prefix(): TypeOrganization,
"metlprj": TypeProject, TypeProject.Prefix(): TypeProject,
"metlusr": TypeUser, TypeUser.Prefix(): TypeUser,
}
// ObjectType defines a type of object.
type ObjectType string
// Prefix returns the objects id prefix.
func (t ObjectType) Prefix() string {
switch t {
case TypeOrganization:
return "metlorg"
case TypeProject:
return "metlprj"
case TypeUser:
return "metlusr"
default:
return ""
}
}
// String returns a string fo the object type.
func (t ObjectType) String() string {
return string(t)
} }
// Service defines a bridge service methods // Service defines a bridge service methods
@@ -47,39 +69,48 @@ type Service interface {
// IsUser checks if the provided id has an id prefix which is a user. // IsUser checks if the provided id has an id prefix which is a user.
IsUser(id gidx.PrefixedID) bool IsUser(id gidx.PrefixedID) bool
// TouchUser triggers a sync of a user and their permissions. // AssignUser assigns a user to the given resource.
TouchUser(ctx context.Context, id gidx.PrefixedID) error AssignUser(ctx context.Context, userID gidx.PrefixedID, resourceIDs ...gidx.PrefixedID) error
// DeleteUser deletes the user and their permissions. // UnassignUser removes the users from the given resource.
DeleteUser(ctx context.Context, id gidx.PrefixedID) error UnassignUser(ctx context.Context, userID gidx.PrefixedID, resourceIDs ...gidx.PrefixedID) error
// IsAssignableResource checks if the provided resource ID may have assigned users.
IsAssignableResource(id gidx.PrefixedID) bool
}
// EventPublisher defines the required methods to publish events.
type EventPublisher interface {
PublishChange(ctx context.Context, subjectType string, change events.ChangeMessage) error
} }
var _ Service = &service{} var _ Service = &service{}
type service struct { type service struct {
logger *zap.SugaredLogger logger *zap.SugaredLogger
publisher *events.Publisher publisher EventPublisher
metal *metal.Client metal metal.Client
perms *permissions.Client perms permissions.Client
idPrefixMap map[string]string idPrefixMap map[string]ObjectType
rootResource rootResource rootResource prefixedID
roles map[string][]string roles map[string][]string
} }
type rootResource struct { type prefixedID struct {
id gidx.PrefixedID id gidx.PrefixedID
} }
func (r rootResource) PrefixedID() gidx.PrefixedID { func (r prefixedID) PrefixedID() gidx.PrefixedID {
return r.id return r.id
} }
func New(publisher *events.Publisher, metal *metal.Client, perms *permissions.Client, options ...Option) (Service, error) { // New creates a new service.
func New(publisher EventPublisher, metal metal.Client, perms permissions.Client, options ...Option) (Service, error) {
svc := &service{ svc := &service{
publisher: publisher, publisher: publisher,
metal: metal, metal: metal,
perms: perms, perms: perms,
idPrefixMap: make(map[string]string), idPrefixMap: make(map[string]ObjectType),
} }
for _, opt := range options { for _, opt := range options {

View File

@@ -6,6 +6,7 @@ import (
"go.infratographer.com/x/gidx" "go.infratographer.com/x/gidx"
) )
// IsUser checks the provided id has the metal user prefix.
func (s *service) IsUser(id gidx.PrefixedID) bool { func (s *service) IsUser(id gidx.PrefixedID) bool {
if idType, ok := s.idPrefixMap[id.Prefix()]; ok { if idType, ok := s.idPrefixMap[id.Prefix()]; ok {
return idType == TypeUser return idType == TypeUser
@@ -14,10 +15,134 @@ func (s *service) IsUser(id gidx.PrefixedID) bool {
return false return false
} }
func (s *service) TouchUser(ctx context.Context, id gidx.PrefixedID) error { // IsAssignableResource checks that the provided id is an id which can have memberships assignments.
func (s *service) IsAssignableResource(id gidx.PrefixedID) bool {
if idType, ok := s.idPrefixMap[id.Prefix()]; ok {
switch idType {
case TypeOrganization, TypeProject:
return true
default:
return false
}
}
return false
}
// Assignuser assigns the provided users to the given resource ids.
func (s *service) AssignUser(ctx context.Context, userID gidx.PrefixedID, resourceIDs ...gidx.PrefixedID) error {
var totalResources, rolesChanged, assignmentsChanged int
mlogger := s.logger.With("member.id", userID.String())
memberID := prefixedID{userID}
for _, resourceID := range resourceIDs {
role, err := s.getUserResourceRole(ctx, userID, resourceID)
if err != nil {
mlogger.Warnw("failed to determine role for user resource", "error", err)
continue
}
if role == "" {
continue
}
roles, assignments := s.processMemberships(ctx, Relationships{
Resource: prefixedID{resourceID},
Memberships: []ResourceMemberships{
{
Role: role,
Member: memberID,
},
},
}, true)
totalResources++
rolesChanged += roles
assignmentsChanged += assignments
}
mlogger.Infow("assignment sync complete",
"membership.roles_changed", rolesChanged,
"membership.assignments_changed", assignmentsChanged,
)
return nil return nil
} }
func (s *service) DeleteUser(ctx context.Context, id gidx.PrefixedID) error { // UnassignUser removes the assignment for the provided user id to the given resources.
func (s *service) UnassignUser(ctx context.Context, userID gidx.PrefixedID, resourceIDs ...gidx.PrefixedID) error {
for _, resourceID := range resourceIDs {
rlogger := s.logger.With("user.id", userID, "resource.id", resourceID)
role, err := s.getUserResourceRole(ctx, userID, resourceID)
if err != nil {
rlogger.Warnw("failed to determine role for user resource", "error", err)
continue
}
if role == "" {
continue
}
actions := s.roles[role]
rlogger = rlogger.With("role.name", role, "role.actions", actions)
resourceRole, err := s.perms.FindResourceRoleByActions(ctx, resourceID, actions)
if err != nil {
rlogger.Warnw("failed to find role by actions for resource", "error", err)
continue
}
rlogger = rlogger.With("role.id", resourceRole.ID)
assigned, err := s.perms.RoleHasAssignment(ctx, resourceRole.ID, userID)
if err != nil {
rlogger.Warnw("failed to check role assignment", "error", err)
continue
}
if !assigned {
rlogger.Warnw("unable to unassign member which is not assigned")
continue
}
if err = s.perms.UnassignRole(ctx, resourceRole.ID, userID); err != nil {
rlogger.Errorw("failed to unassign member from role", "error", err)
continue
}
}
return nil return nil
} }
// getuserResourceRole fetches the appropriate object types user role for the given resource.
func (s *service) getUserResourceRole(ctx context.Context, userID, resourceID gidx.PrefixedID) (string, error) {
var (
role string
err error
)
if idType, ok := s.idPrefixMap[resourceID.Prefix()]; ok {
switch idType {
case TypeOrganization:
role, err = s.metal.GetUserOrganizationRole(ctx, userID, resourceID)
case TypeProject:
role, err = s.metal.GetUserProjectRole(ctx, userID, resourceID)
}
}
if err != nil {
return "", err
}
return role, nil
}