Skip to content

Commit 17c005e

Browse files
committed
refactoring done
- official netbox library used - check command with filter support - in,out command currently only noop - Container image creation updated and documented - Golang image for the build stage - Distroless base without libc and shell for the final image - Concourse config documented
1 parent 846eb28 commit 17c005e

File tree

20 files changed

+735
-257
lines changed

20 files changed

+735
-257
lines changed

.dockerignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
.git/
21
.idea/
2+
.vscode/
3+
.DS_Store

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
.vscode/
2+
.DS_Store

Dockerfile

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,41 @@
1-
FROM keppel.eu-de-1.cloud.sap/ccloud-dockerhub-mirror/library/golang:alpine AS builder
1+
ARG BUILDER_NAME="golang"
2+
ARG BUILDER_VERSION="1.24.4-bookworm"
3+
ARG BASE_NAME="gcr.io/distroless/static-debian12"
4+
ARG BASE_VERSION="latest"
5+
FROM $BUILDER_NAME:$BUILDER_VERSION AS buildstage
6+
ARG BUILD_VERSION
27

3-
RUN apk update && apk add --no-cache git ca-certificates && update-ca-certificates
8+
ENV CGO_ENABLED=0
49

5-
ADD . /app/
6-
WORKDIR /app
7-
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o resource .
8-
RUN mkdir -p /target/opt/resource/
9-
RUN cp resource /target/opt/resource/
10-
RUN ln -s resource /target/opt/resource/in
11-
RUN ln -s resource /target/opt/resource/out
12-
RUN ln -s resource /target/opt/resource/check
10+
COPY . /go/src
11+
SHELL ["/bin/bash", "-euo", "pipefail", "-c"]
1312

13+
RUN mkdir -p /opt/resource
14+
WORKDIR /go/src
15+
RUN \
16+
export GIT_COMMIT="$(git rev-parse HEAD)" \
17+
&& export BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
18+
&& export ENV LDFLAGS="-X 'k8s.io/component-base/version.gitCommit=${GIT_COMMIT}' -X 'k8s.io/component-base/version.buildDate=${BUILD_DATE}' -X 'k8s.io/component-base/version.gitVersion=${BUILD_VERSION}'" \
19+
&& go build -ldflags "${LDFLAGS}" -o /opt/resource/check cmd/check/main.go \
20+
&& go build -ldflags "${LDFLAGS}" -o /opt/resource/in cmd/in/main.go \
21+
&& go build -ldflags "${LDFLAGS}" -o /opt/resource/out cmd/out/main.go
1422

15-
FROM scratch
16-
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
17-
COPY --from=builder /target/opt /opt
23+
RUN /opt/resource/check --version | grep -q "${BUILD_VERSION}"
24+
RUN /opt/resource/in --version | grep -q "${BUILD_VERSION}"
25+
RUN /opt/resource/out --version | grep -q "${BUILD_VERSION}"
26+
27+
ARG BASE_NAME="gcr.io/distroless/static-debian12"
28+
ARG BASE_VERSION="latest"
29+
FROM $BASE_NAME:$BASE_VERSION
30+
COPY --from=buildstage /opt/resource /opt/resource
31+
ARG BASE_NAME="gcr.io/distroless/static-debian12"
32+
ARG BASE_VERSION="latest"
33+
ARG BUILD_VERSION
34+
35+
LABEL org.opencontainers.image.title=concourse-netbox-resource
36+
LABEL org.opencontainers.image.authors="businessbean, SchwarzM"
37+
LABEL org.opencontainers.image.url="https://github.com/sapcc/concourse-netbox-resource/blob/master/Dockerfile"
38+
LABEL org.opencontainers.image.version="${BUILD_VERSION}"
39+
LABEL org.opencontainers.image.base.name="${BASE_NAME}"
40+
LABEL org.opencontainers.image.base.digest="${BASE_NAME}:${BASE_VERSION}"
41+
LABEL source_repository="https://github.com/sapcc/concourse-netbox-resource/"

README.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,65 @@
11
# concourse-netbox-resource
22
A concourse resource to trigger from netbox
3+
4+
## Container Image build
5+
6+
This resource is built using a Dockerfile that uses a multi-stage build process. The first stage builds the Go application, and the second stage creates a minimal container image using distroless. The following optional build arguments are available:
7+
8+
- `BUILDER_NAME`: The name of the Go builder image (default: `golang`)
9+
- `BUILDER_VERSION`: The version of the Go builder image (default: `1.24.4-bookworm`)
10+
- `BASE_NAME`: The base image for the final container (default: `gcr.io/distroless/static-debian12`)
11+
- `BASE_VERSION`: The version of the base image (default: `latest`) (`nonroot` is not possible because Concourse [requires root permissions to run the resource](https://github.com/concourse/concourse/issues/403))
12+
13+
The following build arguments are mandatory:
14+
15+
- `BUILD_VERSION`: The semantic version for the container and the Go application
16+
17+
```shell=bash
18+
export BUILD_VERSION=0.1.0
19+
docker build --build-arg BUILD_VERSION="${BUILD_VERSION}" --tag concourse-netbox-resource:"${BUILD_VERSION}"-"$(date -u +'%Y%m%d%H%M%S')" ./
20+
unset BUILD_VERSION
21+
```
22+
23+
## Usage
24+
25+
This resource is designed to be used in a Concourse CI pipeline. It can be configured to trigger jobs based on events from NetBox, such as changes to devices or other objects.
26+
27+
### Configuration
28+
29+
The `source.url` parameter is mandatory. All fields in the `source.filter` section and the `source.token` are optional. Fields with brackets `[]` can contain multiple values. `source.filter.device_name` and `source.filter.interface_name` are using a `case-insensitive contains` filter.
30+
31+
This is an example of how to configure the resource in a Concourse pipeline:
32+
33+
```yaml
34+
resource_types:
35+
- name: netbox-resource
36+
type: registry-image
37+
check_every: never
38+
source:
39+
repository: registry.fqdn/org/concourse-netbox-resource
40+
tag: 0.1.0-20250619163905
41+
42+
resources:
43+
- name: example.netbox
44+
type: netbox-resource
45+
icon: netbox
46+
check_every: 15m
47+
source:
48+
url: "https://netbox.example.local"
49+
token: "your-api-token"
50+
filter:
51+
site_name: ["site 1"]
52+
tag: ["tag1"]
53+
role: ["server"]
54+
device_id: [123]
55+
device_name: ["server1"]
56+
device_type: ["vendor model"]
57+
device_status: ["active"]
58+
server_interface:
59+
interface_id: [456]
60+
interface_name: ["eth0"]
61+
mgmt_only: false
62+
connected: true
63+
cabled: true
64+
type: ["virtual"]
65+
```

cmd/check/main.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
9+
"github.com/sapcc/concourse-netbox-resource/pkg/concourse"
10+
"github.com/sapcc/concourse-netbox-resource/pkg/helper"
11+
"github.com/sapcc/concourse-netbox-resource/pkg/netbox"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
var (
16+
input concourse.Input
17+
output []concourse.Version
18+
err error
19+
20+
checkCmd = &cobra.Command{
21+
Use: "check",
22+
Short: "Concourse resource check command",
23+
Long: `This command queries the NetBox API for objects matching the specified filter and returns their latest versions.
24+
The input is required in JSON format from stdin, which should include the NetBox URL, the API token and optional filters.
25+
The output will be a JSON array of objects with their latest versions.
26+
27+
{
28+
"source": {
29+
"url": "https://netbox.example.local",
30+
"token": "your-api-token"
31+
},
32+
"filter": {
33+
"site_name": ["My Site"],
34+
"tag": ["my-tag"],
35+
"role": ["server"],
36+
"device_id": [123],
37+
"device_name": ["my-server"],
38+
"device_type": ["server type"],
39+
"device_status": ["active"],
40+
"server_interface": {
41+
"interface_id": [456],
42+
"interface_name": ["eth0"],
43+
"mgmt_only": false,
44+
"connected": true,
45+
"cabled": true,
46+
"type": ["1000base-t"]
47+
}
48+
},
49+
"version": {
50+
"id": 123,
51+
"last_updated": "2023-10-01T12:00:00Z",
52+
"object_type": "interfaces",
53+
"device_id": 123,
54+
"device_name": "my-server",
55+
"device_role": "server",
56+
"interface_name": "eth0",
57+
"interface_type": "1000base-t"
58+
}
59+
}
60+
`,
61+
Example: `check < source.json`,
62+
DisableFlagsInUseLine: true,
63+
CompletionOptions: cobra.CompletionOptions{
64+
DisableDefaultCmd: true,
65+
},
66+
RunE: func(cmd *cobra.Command, args []string) error {
67+
buildInfo, _ := cmd.Flags().GetBool("buildinfo")
68+
if buildInfo {
69+
cmd.Println(helper.ShowBuildInfo())
70+
os.Exit(0)
71+
}
72+
73+
versionInfo, _ := cmd.Flags().GetBool("version")
74+
if versionInfo {
75+
cmd.Println(helper.ShowVersionInfo())
76+
os.Exit(0)
77+
}
78+
79+
input, err = validateInput()
80+
if err != nil {
81+
return fmt.Errorf("input validation failed: %w", err)
82+
}
83+
84+
context := cmd.Context()
85+
output, err = netbox.Query(input, context)
86+
if err != nil {
87+
return fmt.Errorf("netbox query failed: %w", err)
88+
}
89+
90+
encodedOutput, encodeErr := json.Marshal(output)
91+
if encodeErr != nil {
92+
return fmt.Errorf("failed to Json encode query result: %w (output: %+v)", encodeErr, output)
93+
}
94+
if _, writeErr := os.Stdout.Write(encodedOutput); writeErr != nil {
95+
return fmt.Errorf("failed to output Json encoded query result: %w (output: %s)", writeErr, string(encodedOutput))
96+
}
97+
return nil
98+
},
99+
}
100+
)
101+
102+
func init() {
103+
helper.AddFlags(checkCmd)
104+
checkCmd.SetOut(os.Stdout)
105+
}
106+
107+
func main() {
108+
if err := checkCmd.Execute(); err != nil {
109+
panic(err)
110+
}
111+
}
112+
113+
func validateInput() (concourse.Input, error) {
114+
var inputParsed concourse.Input
115+
116+
stat, _ := os.Stdin.Stat()
117+
if (stat.Mode() & os.ModeCharDevice) != 0 {
118+
return concourse.Input{}, fmt.Errorf("no input provided on stdin")
119+
}
120+
121+
err := json.NewDecoder(os.Stdin).Decode(&inputParsed)
122+
if err != nil && err != io.EOF {
123+
return concourse.Input{}, fmt.Errorf("failed to decode input")
124+
}
125+
126+
if inputParsed.Source.Url == "" {
127+
return concourse.Input{}, fmt.Errorf("source.netbox_url containing the NetBox URL is required")
128+
}
129+
return inputParsed, nil
130+
}

cmd/in/main.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
9+
"github.com/sapcc/concourse-netbox-resource/pkg/concourse"
10+
"github.com/sapcc/concourse-netbox-resource/pkg/helper"
11+
"github.com/spf13/cobra"
12+
)
13+
14+
var (
15+
input concourse.Input
16+
output concourse.Output
17+
inCmd = &cobra.Command{
18+
Use: "in [destination]",
19+
Short: "Concourse resource in command (noop)",
20+
Long: `This command implements the Concourse in interface as a noop. It reads the input, validates it, and outputs the version.`,
21+
Example: `in /tmp/build/put < request.json`,
22+
DisableFlagsInUseLine: true,
23+
CompletionOptions: cobra.CompletionOptions{
24+
DisableDefaultCmd: true,
25+
},
26+
RunE: func(cmd *cobra.Command, args []string) error {
27+
buildInfo, _ := cmd.Flags().GetBool("buildinfo")
28+
if buildInfo {
29+
cmd.Println(helper.ShowBuildInfo())
30+
os.Exit(0)
31+
}
32+
33+
versionInfo, _ := cmd.Flags().GetBool("version")
34+
if versionInfo {
35+
cmd.Println(helper.ShowVersionInfo())
36+
os.Exit(0)
37+
}
38+
39+
stat, _ := os.Stdin.Stat()
40+
if (stat.Mode() & os.ModeCharDevice) != 0 {
41+
return fmt.Errorf("no input provided on stdin")
42+
}
43+
err := json.NewDecoder(os.Stdin).Decode(&input)
44+
if err != nil && err != io.EOF {
45+
return fmt.Errorf("failed to decode input")
46+
}
47+
48+
if len(args) < 1 {
49+
return fmt.Errorf("destination path argument is required")
50+
}
51+
outPath := args[0]
52+
if outPath != "/tmp/build/get" {
53+
return fmt.Errorf("invalid destination path: %s", outPath)
54+
}
55+
file, err := os.Create(outPath + "/netbox-version.json")
56+
if err != nil {
57+
return fmt.Errorf("failed to create output file: %w", err)
58+
}
59+
defer file.Close()
60+
61+
output.Version.Id = input.Version.Id
62+
output.Version.LastUpdated = input.Version.LastUpdated
63+
output.Version.ObjectType = input.Version.ObjectType
64+
output.Version.DeviceId = input.Version.DeviceId
65+
output.Version.DeviceName = input.Version.DeviceName
66+
output.Version.DeviceRole = input.Version.DeviceRole
67+
output.Version.DeviceApiUrl = input.Version.DeviceApiUrl
68+
output.Version.DeviceDisplayUrl = input.Version.DeviceDisplayUrl
69+
if input.Version.ObjectType == "interfaces" {
70+
output.Version.InterfaceName = input.Version.InterfaceName
71+
output.Version.InterfaceType = input.Version.InterfaceType
72+
output.Version.InterfaceApiUrl = input.Version.InterfaceApiUrl
73+
output.Version.InterfaceDisplayUrl = input.Version.InterfaceDisplayUrl
74+
}
75+
76+
if err := json.NewEncoder(file).Encode(output); err != nil {
77+
return fmt.Errorf("failed to write JSON output: %w", err)
78+
}
79+
return json.NewEncoder(os.Stdout).Encode(output)
80+
},
81+
}
82+
)
83+
84+
func init() {
85+
helper.AddFlags(inCmd)
86+
inCmd.SetOut(os.Stdout)
87+
}
88+
89+
func main() {
90+
if err := inCmd.Execute(); err != nil {
91+
panic(err)
92+
}
93+
}

0 commit comments

Comments
 (0)