Skip to content

Commit 3afd477

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 3afd477

File tree

20 files changed

+736
-258
lines changed

20 files changed

+736
-258
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.25.0-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 -v | grep -q "${BUILD_VERSION}"
24+
RUN /opt/resource/in -v | grep -q "${BUILD_VERSION}"
25+
RUN /opt/resource/out -v | grep -q "${BUILD_VERSION}"
26+
27+
ARG BASE_NAME
28+
ARG BASE_VERSION
29+
FROM $BASE_NAME:$BASE_VERSION
30+
ARG BASE_NAME
31+
ARG BASE_VERSION
32+
ARG BUILD_VERSION
33+
COPY --from=buildstage /opt/resource /opt/resource
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: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,66 @@
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+
enabled: true
62+
mgmt_only: false
63+
connected: true
64+
cabled: true
65+
type: ["virtual"]
66+
```

cmd/check/main.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"flag"
7+
"fmt"
8+
"io"
9+
"os"
10+
11+
"github.com/sapcc/concourse-netbox-resource/pkg/concourse"
12+
"github.com/sapcc/concourse-netbox-resource/pkg/helper"
13+
"github.com/sapcc/concourse-netbox-resource/pkg/netbox"
14+
)
15+
16+
var (
17+
input concourse.Input
18+
output []concourse.Version
19+
err error
20+
cmdlineflags helper.CmdLineFlags
21+
ctx context.Context
22+
usage string = `This command queries the NetBox API for objects matching the specified filter and returns their latest versions.
23+
The input is required in JSON format from stdin, which should include the NetBox URL, the API token and optional filters.
24+
The output will be a JSON array of objects with their latest versions.
25+
26+
{
27+
"source": {
28+
"url": "https://netbox.example.local",
29+
"token": "your-api-token"
30+
},
31+
"filter": {
32+
"site_name": ["My Site"],
33+
"tag": ["my-tag"],
34+
"role": ["server"],
35+
"device_id": [123],
36+
"device_name": ["my-server"],
37+
"device_type": ["server type"],
38+
"device_status": ["active"],
39+
"server_interface": {
40+
"interface_id": [456],
41+
"interface_name": ["eth0"],
42+
"enabled": true,
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+
Parameters:
63+
`
64+
)
65+
66+
func init() {
67+
cmdlineflags = helper.AddFlags()
68+
ctx = context.Background()
69+
}
70+
71+
func main() {
72+
if cmdlineflags.Buildinfo {
73+
os.Stdout.WriteString(helper.ShowBuildInfo())
74+
os.Exit(0)
75+
}
76+
77+
if cmdlineflags.Versioninfo {
78+
os.Stdout.WriteString(helper.ShowVersionInfo())
79+
os.Exit(0)
80+
}
81+
82+
if cmdlineflags.Help {
83+
os.Stdout.WriteString(usage)
84+
flag.PrintDefaults()
85+
os.Exit(0)
86+
}
87+
88+
input, err = validateInput()
89+
if err != nil {
90+
fmt.Println(fmt.Errorf("input validation failed: %w", err))
91+
os.Exit(1)
92+
}
93+
94+
output, err = netbox.Query(input, ctx)
95+
if err != nil {
96+
fmt.Println(fmt.Errorf("netbox query failed: %w", err))
97+
os.Exit(1)
98+
}
99+
100+
encodedOutput, encodeErr := json.Marshal(output)
101+
if encodeErr != nil {
102+
fmt.Println(fmt.Errorf("failed to Json encode query result: %w (output: %+v)", encodeErr, output))
103+
os.Exit(1)
104+
}
105+
106+
if _, writeErr := os.Stdout.Write(encodedOutput); writeErr != nil {
107+
fmt.Println(fmt.Errorf("failed to output Json encoded query result: %w (output: %s)", writeErr, string(encodedOutput)))
108+
os.Exit(1)
109+
}
110+
}
111+
112+
func validateInput() (concourse.Input, error) {
113+
var inputParsed concourse.Input
114+
115+
stat, _ := os.Stdin.Stat()
116+
if (stat.Mode() & os.ModeCharDevice) != 0 {
117+
return concourse.Input{}, fmt.Errorf("no input provided on stdin")
118+
}
119+
120+
err := json.NewDecoder(os.Stdin).Decode(&inputParsed)
121+
if err != nil && err != io.EOF {
122+
return concourse.Input{}, fmt.Errorf("failed to decode input")
123+
}
124+
125+
if inputParsed.Source.Url == "" {
126+
return concourse.Input{}, fmt.Errorf("source.netbox_url containing the NetBox URL is required")
127+
}
128+
return inputParsed, nil
129+
}

cmd/in/main.go

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

0 commit comments

Comments
 (0)