mirror of
https://github.com/davidallendj/magellan.git
synced 2025-12-20 03:27:03 -07:00
inital release refactor
This commit is contained in:
parent
34367f830a
commit
3351de48a6
12 changed files with 215 additions and 84 deletions
40
.goreleaser.yaml
Normal file
40
.goreleaser.yaml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
before:
|
||||
hooks:
|
||||
- go mod download
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
- windows
|
||||
goarch:
|
||||
- amd64
|
||||
archives:
|
||||
- format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
dockers:
|
||||
- image_templates:
|
||||
- "bikeshack.azurecr.io/magellan:latest"
|
||||
- "bikeshack.azurecr.io/magellan:{{ .Major }}"
|
||||
- "bikeshack.azurecr.io/magellan:{{ .Major }}.{{ .Minor }}"
|
||||
- "bikeshack.azurecr.io/magellan:{{ .Major }}.{{ .Minor }}.{{ .Patch }}"
|
||||
dockerfile: goreleaser.Dockerfile
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--no-cache"
|
||||
- "--build-arg=REGISTRY_HOST=docker.io/library/"
|
||||
# OCI annotations: https://github.com/opencontainers/image-spec/blob/main/annotations.md
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.name={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--label=org.opencontainers.image.source={{.GitURL}}"
|
||||
release:
|
||||
github:
|
||||
name_template: "{{.Version}}"
|
||||
prerelease: auto
|
||||
mode: append
|
||||
changelog:
|
||||
skip: true
|
||||
87
Makefile
Normal file
87
Makefile
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# import config.
|
||||
# You can change the default config with `make cnf="config_special.env" build`
|
||||
cnf ?= config.env
|
||||
include $(cnf)
|
||||
export $(shell sed 's/=.*//' $(cnf))
|
||||
|
||||
ifndef NAME
|
||||
$(error NAME is not set. Please review and copy config.env.default to config.env and try again)
|
||||
endif
|
||||
|
||||
ifndef VERSION
|
||||
$(error VERSION is not set. Please review and copy config.env.default to config.env and try again)
|
||||
endif
|
||||
|
||||
SHELL := /bin/bash
|
||||
|
||||
.DEFAULT_GOAL := all
|
||||
.PHONY: all
|
||||
all: ## build pipeline
|
||||
all: mod inst build spell lint test
|
||||
|
||||
.PHONY: ci
|
||||
ci: ## CI build pipeline
|
||||
ci: all diff
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: clean
|
||||
clean: ## remove files created during build pipeline
|
||||
$(call print-target)
|
||||
rm -rf dist
|
||||
rm -f coverage.*
|
||||
rm -f '"$(shell go env GOCACHE)/../golangci-lint"'
|
||||
go clean -i -cache -testcache -modcache -fuzzcache -x
|
||||
|
||||
.PHONY: mod
|
||||
mod: ## go mod tidy
|
||||
$(call print-target)
|
||||
go mod tidy
|
||||
|
||||
.PHONY: inst
|
||||
inst: ## go install tools
|
||||
$(call print-target)
|
||||
go install github.com/client9/misspell/cmd/misspell@v0.3.4
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.52.2
|
||||
go install github.com/goreleaser/goreleaser@v1.18.2
|
||||
|
||||
.PHONY: build
|
||||
build: ## goreleaser build
|
||||
build:
|
||||
$(call print-target)
|
||||
goreleaser build --clean --single-target --snapshot
|
||||
|
||||
.PHONY: docker
|
||||
docker: ## docker build
|
||||
docker:
|
||||
$(call print-target)
|
||||
docker build . --build-arg REGISTRY_HOST=${REGISTRY_HOST} --no-cache --pull --tag '${NAME}:${VERSION}'
|
||||
|
||||
.PHONY: spell
|
||||
spell: ## misspell
|
||||
$(call print-target)
|
||||
misspell -error -locale=US -w **.md
|
||||
|
||||
.PHONY: lint
|
||||
lint: ## golangci-lint
|
||||
$(call print-target)
|
||||
golangci-lint run --fix
|
||||
|
||||
.PHONY: test
|
||||
test: ## go test
|
||||
$(call print-target)
|
||||
go test -race -covermode=atomic -coverprofile=coverage.out -coverpkg=./... ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
.PHONY: diff
|
||||
diff: ## git diff
|
||||
$(call print-target)
|
||||
git diff --exit-code
|
||||
RES=$$(git status --porcelain) ; if [ -n "$$RES" ]; then echo $$RES && exit 1 ; fi
|
||||
|
||||
|
||||
define print-target
|
||||
@printf "Executing target: \033[36m$@\033[0m\n"
|
||||
endef
|
||||
|
|
@ -1,18 +1,17 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
magellan "davidallendj/magellan/internal"
|
||||
"davidallendj/magellan/internal/api/smd"
|
||||
"davidallendj/magellan/internal/db/sqlite"
|
||||
magellan "github.com/bikeshack/magellan/internal"
|
||||
"github.com/bikeshack/magellan/internal/api/smd"
|
||||
"github.com/bikeshack/magellan/internal/db/sqlite"
|
||||
|
||||
"github.com/cznic/mathutil"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
var collectCmd = &cobra.Command{
|
||||
Use: "collect",
|
||||
Use: "collect",
|
||||
Short: "Query information about BMC",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// make application logger
|
||||
|
|
@ -28,13 +27,13 @@ var collectCmd = &cobra.Command{
|
|||
threads = mathutil.Clamp(len(probeStates), 1, 255)
|
||||
}
|
||||
q := &magellan.QueryParams{
|
||||
User: user,
|
||||
Pass: pass,
|
||||
Drivers: drivers,
|
||||
Timeout: timeout,
|
||||
Threads: threads,
|
||||
Verbose: verbose,
|
||||
WithSecureTLS: withSecureTLS,
|
||||
User: user,
|
||||
Pass: pass,
|
||||
Drivers: drivers,
|
||||
Timeout: timeout,
|
||||
Threads: threads,
|
||||
Verbose: verbose,
|
||||
WithSecureTLS: withSecureTLS,
|
||||
}
|
||||
magellan.CollectInfo(&probeStates, l, q)
|
||||
|
||||
|
|
@ -46,7 +45,7 @@ var collectCmd = &cobra.Command{
|
|||
},
|
||||
}
|
||||
|
||||
func init(){
|
||||
func init() {
|
||||
collectCmd.PersistentFlags().StringSliceVar(&drivers, "driver", []string{"redfish"}, "set the driver(s) and fallback drivers to use")
|
||||
collectCmd.PersistentFlags().StringVar(&smd.Host, "host", smd.Host, "set the host to the smd API")
|
||||
collectCmd.PersistentFlags().IntVar(&smd.Port, "port", smd.Port, "set the port to the smd API")
|
||||
|
|
@ -58,4 +57,4 @@ func init(){
|
|||
collectCmd.PersistentFlags().BoolVar(&withSecureTLS, "secure-tls", false, "enable secure TLS")
|
||||
collectCmd.PersistentFlags().StringVar(&certPoolFile, "cert-pool", "", "path to CA cert. (defaults to system CAs; used with --secure-tls=true)")
|
||||
rootCmd.AddCommand(collectCmd)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"davidallendj/magellan/internal/db/sqlite"
|
||||
"fmt"
|
||||
|
||||
"github.com/bikeshack/magellan/internal/db/sqlite"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
var listCmd = &cobra.Command{
|
||||
Use: "list",
|
||||
Use: "list",
|
||||
Short: "List information from scan",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
probeResults, err := sqlite.GetProbeResults(dbpath)
|
||||
|
|
@ -25,4 +25,4 @@ var listCmd = &cobra.Command{
|
|||
|
||||
func init() {
|
||||
rootCmd.AddCommand(listCmd)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
magellan "davidallendj/magellan/internal"
|
||||
"davidallendj/magellan/internal/db/sqlite"
|
||||
"fmt"
|
||||
|
||||
magellan "github.com/bikeshack/magellan/internal"
|
||||
"github.com/bikeshack/magellan/internal/db/sqlite"
|
||||
|
||||
"github.com/cznic/mathutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
|
|||
5
config.env
Normal file
5
config.env
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
## This configuration is used by the Makefile
|
||||
|
||||
NAME=magellan
|
||||
VERSION=$(shell git describe --tags --abbrev=0)
|
||||
|
||||
8
go.mod
8
go.mod
|
|
@ -1,10 +1,9 @@
|
|||
module davidallendj/magellan
|
||||
module github.com/bikeshack/magellan
|
||||
|
||||
go 1.20
|
||||
|
||||
replace github.com/bmc-toolbox/dora => ../../dora
|
||||
|
||||
require (
|
||||
github.com/Cray-HPE/hms-xname v1.3.0
|
||||
github.com/bmc-toolbox/bmclib/v2 v2.0.1-0.20230714152943-a1b87e2ff47f
|
||||
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548
|
||||
github.com/jacobweinstock/registrar v0.4.7
|
||||
|
|
@ -13,10 +12,10 @@ require (
|
|||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.7.0
|
||||
github.com/stmcginnis/gofish v0.14.0
|
||||
golang.org/x/exp v0.0.0-20230127130021-4ca2cb1a16b7
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Cray-HPE/hms-xname v1.3.0 // indirect
|
||||
github.com/VictorLowther/simplexml v0.0.0-20180716164440-0bff93621230 // indirect
|
||||
github.com/VictorLowther/soap v0.0.0-20150314151524-8e36fca84b22 // indirect
|
||||
github.com/bmc-toolbox/common v0.0.0-20230717121556-5eb9915a8a5a // indirect
|
||||
|
|
@ -31,7 +30,6 @@ require (
|
|||
github.com/satori/go.uuid v1.2.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/stretchr/testify v1.8.3 // indirect
|
||||
golang.org/x/exp v0.0.0-20230127130021-4ca2cb1a16b7 // indirect
|
||||
golang.org/x/net v0.14.0 // indirect
|
||||
golang.org/x/sys v0.11.0 // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||
|
|
|
|||
|
|
@ -1,31 +1,32 @@
|
|||
package dora
|
||||
|
||||
import (
|
||||
"davidallendj/magellan/internal/api"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/bikeshack/magellan/internal/api"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const (
|
||||
Host = "http://localhost"
|
||||
DbType = "sqlite3"
|
||||
DbPath = "../data/assets.db"
|
||||
Host = "http://localhost"
|
||||
DbType = "sqlite3"
|
||||
DbPath = "../data/assets.db"
|
||||
BaseEndpoint = "/v1"
|
||||
Port = 8000
|
||||
Port = 8000
|
||||
)
|
||||
|
||||
type ScannedResult struct {
|
||||
id string
|
||||
site any
|
||||
cidr string
|
||||
ip string
|
||||
port int
|
||||
id string
|
||||
site any
|
||||
cidr string
|
||||
ip string
|
||||
port int
|
||||
protocol string
|
||||
scanner string
|
||||
state string
|
||||
updated string
|
||||
scanner string
|
||||
state string
|
||||
updated string
|
||||
}
|
||||
|
||||
func makeEndpointUrl(endpoint string) string {
|
||||
|
|
@ -67,8 +68,8 @@ func LoadScannedPortsFromDB(dbPath string, dbType string) {
|
|||
for rows.Next() {
|
||||
var r ScannedResult
|
||||
rows.Scan(
|
||||
&r.id, &r.site, &r.cidr, &r.ip, &r.port, &r.protocol, &r.scanner,
|
||||
&r.id, &r.site, &r.cidr, &r.ip, &r.port, &r.protocol, &r.scanner,
|
||||
&r.state, &r.updated,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,18 +4,18 @@ package smd
|
|||
// https://github.com/Cray-HPE/hms-smd/blob/master/docs/examples.adoc
|
||||
// https://github.com/alexlovelltroy/hms-smd
|
||||
import (
|
||||
"davidallendj/magellan/internal/api"
|
||||
"fmt"
|
||||
|
||||
"github.com/bikeshack/magellan/internal/api"
|
||||
// hms "github.com/alexlovelltroy/hms-smd"
|
||||
)
|
||||
|
||||
var (
|
||||
Host = "http://localhost"
|
||||
Host = "http://localhost"
|
||||
BaseEndpoint = "/hsm/v2"
|
||||
Port = 27779
|
||||
Port = 27779
|
||||
)
|
||||
|
||||
|
||||
func makeEndpointUrl(endpoint string) string {
|
||||
return Host + ":" + fmt.Sprint(Port) + BaseEndpoint + endpoint
|
||||
}
|
||||
|
|
@ -49,7 +49,7 @@ func AddRedfishEndpoint(data []byte, headers map[string]string) error {
|
|||
|
||||
// var ep hms.RedfishEP
|
||||
// _ = ep
|
||||
// Add redfish endpoint via POST `/hsm/v2/Inventory/RedfishEndpoints` endpoint
|
||||
// Add redfish endpoint via POST `/hsm/v2/Inventory/RedfishEndpoints` endpoint
|
||||
url := makeEndpointUrl("/Inventory/RedfishEndpoints")
|
||||
res, body, _ := api.MakeRequest(url, "POST", data, headers)
|
||||
fmt.Println("smd url: ", url)
|
||||
|
|
@ -60,4 +60,4 @@ func AddRedfishEndpoint(data []byte, headers map[string]string) error {
|
|||
|
||||
func UpdateRedfishEndpoint() {
|
||||
// Update redfish endpoint via PUT `/hsm/v2/Inventory/RedfishEndpoints` endpoint
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@ package magellan
|
|||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"davidallendj/magellan/internal/api/smd"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bikeshack/magellan/internal/api/smd"
|
||||
|
||||
"github.com/Cray-HPE/hms-xname/xnames"
|
||||
bmclib "github.com/bmc-toolbox/bmclib/v2"
|
||||
"github.com/jacobweinstock/registrar"
|
||||
|
|
@ -42,13 +43,13 @@ type QueryParams struct {
|
|||
User string
|
||||
Pass string
|
||||
Drivers []string
|
||||
Threads int
|
||||
Preferred string
|
||||
Threads int
|
||||
Preferred string
|
||||
Timeout int
|
||||
WithSecureTLS bool
|
||||
CertPoolFile string
|
||||
Verbose bool
|
||||
IpmitoolPath string
|
||||
IpmitoolPath string
|
||||
}
|
||||
|
||||
func NewClient(l *Logger, q *QueryParams) (*bmclib.Client, error) {
|
||||
|
|
@ -120,18 +121,18 @@ func CollectInfo(probeStates *[]BMCProbeResult, l *Logger, q *QueryParams) error
|
|||
if len(*probeStates) <= 0 {
|
||||
return fmt.Errorf("no probe states found")
|
||||
}
|
||||
|
||||
|
||||
// generate custom xnames for bmcs
|
||||
node := xnames.Node{
|
||||
Cabinet: 1000,
|
||||
Chassis: 1,
|
||||
ComputeModule: 7,
|
||||
NodeBMC: -1,
|
||||
Cabinet: 1000,
|
||||
Chassis: 1,
|
||||
ComputeModule: 7,
|
||||
NodeBMC: -1,
|
||||
}
|
||||
|
||||
found := make([]string, 0, len(*probeStates))
|
||||
done := make(chan struct{}, q.Threads+1)
|
||||
chanProbeState := make(chan BMCProbeResult, q.Threads+1)
|
||||
found := make([]string, 0, len(*probeStates))
|
||||
done := make(chan struct{}, q.Threads+1)
|
||||
chanProbeState := make(chan BMCProbeResult, q.Threads+1)
|
||||
|
||||
// collect bmc information asynchronously
|
||||
var wg sync.WaitGroup
|
||||
|
|
@ -139,7 +140,7 @@ func CollectInfo(probeStates *[]BMCProbeResult, l *Logger, q *QueryParams) error
|
|||
for i := 0; i < q.Threads; i++ {
|
||||
go func() {
|
||||
for {
|
||||
ps, ok := <- chanProbeState
|
||||
ps, ok := <-chanProbeState
|
||||
if !ok {
|
||||
wg.Done()
|
||||
return
|
||||
|
|
@ -152,7 +153,7 @@ func CollectInfo(probeStates *[]BMCProbeResult, l *Logger, q *QueryParams) error
|
|||
client, err := NewClient(l, q)
|
||||
if err != nil {
|
||||
l.Log.Errorf("could not make client: %v", err)
|
||||
continue
|
||||
continue
|
||||
}
|
||||
|
||||
// metadata
|
||||
|
|
@ -164,7 +165,7 @@ func CollectInfo(probeStates *[]BMCProbeResult, l *Logger, q *QueryParams) error
|
|||
// inventories
|
||||
inventory, err := QueryInventory(client, l, q)
|
||||
if err != nil {
|
||||
l.Log.Errorf("could not query inventory: %v", err)
|
||||
l.Log.Errorf("could not query inventory: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -181,13 +182,13 @@ func CollectInfo(probeStates *[]BMCProbeResult, l *Logger, q *QueryParams) error
|
|||
headers["Content-Type"] = "application/json"
|
||||
|
||||
data := make(map[string]any)
|
||||
data["ID"] = fmt.Sprintf("%v", node.String()[:len(node.String())-2])
|
||||
data["Type"] = ""
|
||||
data["Name"] = ""
|
||||
data["FQDN"] = ps.Host
|
||||
data["RediscoverOnUpdate"] = false
|
||||
data["Inventory"] = inventory
|
||||
data["Chassis"] = chassis
|
||||
data["ID"] = fmt.Sprintf("%v", node.String()[:len(node.String())-2])
|
||||
data["Type"] = ""
|
||||
data["Name"] = ""
|
||||
data["FQDN"] = ps.Host
|
||||
data["RediscoverOnUpdate"] = false
|
||||
data["Inventory"] = inventory
|
||||
data["Chassis"] = chassis
|
||||
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
|
|
@ -228,7 +229,7 @@ func CollectInfo(probeStates *[]BMCProbeResult, l *Logger, q *QueryParams) error
|
|||
for _, ps := range *probeStates {
|
||||
// skip if found info from host
|
||||
foundHost := slices.Index(found, ps.Host)
|
||||
if !ps.State || foundHost >= 0{
|
||||
if !ps.State || foundHost >= 0 {
|
||||
continue
|
||||
}
|
||||
chanProbeState <- ps
|
||||
|
|
@ -394,11 +395,11 @@ func QueryBios(client *bmclib.Client, l *Logger, q *QueryParams) ([]byte, error)
|
|||
|
||||
func QueryEthernetInterfaces(client *bmclib.Client, l *Logger, q *QueryParams) ([]byte, error) {
|
||||
config := gofish.ClientConfig{
|
||||
Endpoint: fmt.Sprintf("https://%s:%d", q.Host, q.Port),
|
||||
Username: q.User,
|
||||
Password: q.Pass,
|
||||
Insecure: !q.WithSecureTLS,
|
||||
TLSHandshakeTimeout: q.Timeout,
|
||||
Endpoint: fmt.Sprintf("https://%s:%d", q.Host, q.Port),
|
||||
Username: q.User,
|
||||
Password: q.Pass,
|
||||
Insecure: !q.WithSecureTLS,
|
||||
TLSHandshakeTimeout: q.Timeout,
|
||||
}
|
||||
c, err := gofish.Connect(config)
|
||||
if err != nil {
|
||||
|
|
@ -418,12 +419,12 @@ func QueryEthernetInterfaces(client *bmclib.Client, l *Logger, q *QueryParams) (
|
|||
}
|
||||
|
||||
func QueryChassis(q *QueryParams) ([]byte, error) {
|
||||
config := gofish.ClientConfig {
|
||||
Endpoint: fmt.Sprintf("https://%s:%d", q.Host, q.Port),
|
||||
Username: q.User,
|
||||
Password: q.Pass,
|
||||
Insecure: !q.WithSecureTLS,
|
||||
TLSHandshakeTimeout: q.Timeout,
|
||||
config := gofish.ClientConfig{
|
||||
Endpoint: fmt.Sprintf("https://%s:%d", q.Host, q.Port),
|
||||
Username: q.User,
|
||||
Password: q.Pass,
|
||||
Insecure: !q.WithSecureTLS,
|
||||
TLSHandshakeTimeout: q.Timeout,
|
||||
}
|
||||
c, err := gofish.Connect(config)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package sqlite
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
magellan "davidallendj/magellan/internal"
|
||||
magellan "github.com/bikeshack/magellan/internal"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
|
@ -58,4 +58,4 @@ func GetProbeResults(path string) ([]magellan.BMCProbeResult, error) {
|
|||
return nil, fmt.Errorf("could not retrieve probes: %v", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
main.go
3
main.go
|
|
@ -1,8 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"davidallendj/magellan/cmd"
|
||||
// smd "github.com/alexlovelltroy/hms-smd/pkg/redfish"
|
||||
"github.com/bikeshack/magellan/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue